PathfindingScheduler.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Collections.Concurrent;
  4. using System.Threading;
  5. using System.Threading.Tasks;
  6. using UnityEngine;
  7. /// <summary>
  8. /// Thread-safe min-heap (binary heap) priority queue for A* open sets.
  9. /// Lower fScore = higher priority.
  10. /// </summary>
  11. public class MinHeap<T>
  12. {
  13. private readonly List<(float priority, T item)> _heap = new();
  14. public int Count => _heap.Count;
  15. public void Enqueue(T item, float priority)
  16. {
  17. _heap.Add((priority, item));
  18. BubbleUp(_heap.Count - 1);
  19. }
  20. public T Dequeue()
  21. {
  22. var top = _heap[0].item;
  23. int last = _heap.Count - 1;
  24. _heap[0] = _heap[last];
  25. _heap.RemoveAt(last);
  26. if (_heap.Count > 0) SiftDown(0);
  27. return top;
  28. }
  29. public T Peek() => _heap[0].item;
  30. public bool Contains(T item, out float priority)
  31. {
  32. for (int i = 0; i < _heap.Count; i++)
  33. {
  34. if (EqualityComparer<T>.Default.Equals(_heap[i].item, item))
  35. {
  36. priority = _heap[i].priority;
  37. return true;
  38. }
  39. }
  40. priority = float.MaxValue;
  41. return false;
  42. }
  43. public void UpdatePriority(T item, float newPriority)
  44. {
  45. for (int i = 0; i < _heap.Count; i++)
  46. {
  47. if (EqualityComparer<T>.Default.Equals(_heap[i].item, item))
  48. {
  49. _heap[i] = (newPriority, item);
  50. BubbleUp(i);
  51. SiftDown(i);
  52. return;
  53. }
  54. }
  55. }
  56. private void BubbleUp(int i)
  57. {
  58. while (i > 0)
  59. {
  60. int parent = (i - 1) / 2;
  61. if (_heap[parent].priority <= _heap[i].priority) break;
  62. (_heap[parent], _heap[i]) = (_heap[i], _heap[parent]);
  63. i = parent;
  64. }
  65. }
  66. private void SiftDown(int i)
  67. {
  68. int n = _heap.Count;
  69. while (true)
  70. {
  71. int smallest = i;
  72. int left = 2 * i + 1, right = 2 * i + 2;
  73. if (left < n && _heap[left].priority < _heap[smallest].priority) smallest = left;
  74. if (right < n && _heap[right].priority < _heap[smallest].priority) smallest = right;
  75. if (smallest == i) break;
  76. (_heap[smallest], _heap[i]) = (_heap[i], _heap[smallest]);
  77. i = smallest;
  78. }
  79. }
  80. }
  81. /// <summary>
  82. /// A single pathfinding request queued by an AIAgent.
  83. /// </summary>
  84. public class PathRequest
  85. {
  86. public int AgentId;
  87. public Vector2Int Start;
  88. public Vector2Int Goal;
  89. public MazeRoom RoomContext; // null = open hallway A*
  90. public bool IsHallwayMode; // true = FindPathToNearestRoom style
  91. public Action<List<Vector2Int>> Callback; // Called on main thread with result
  92. }
  93. /// <summary>
  94. /// Schedules A* pathfinding requests on worker threads and delivers results back
  95. /// to the main thread on the next frame.
  96. ///
  97. /// Usage: instead of calling FindPathInRoom() directly in Update(), agents call
  98. /// PathfindingScheduler.Instance.RequestPath(request)
  99. /// and supply a callback that will fire on the main thread.
  100. ///
  101. /// Worker threads process up to <see cref="MaxConcurrentJobs"/> requests in parallel.
  102. /// This keeps Unity's main thread free for rendering and physics while the CPU
  103. /// cores that were sitting idle (the user observed CPU at ~1%) do the heavy lifting.
  104. /// </summary>
  105. public class PathfindingScheduler : MonoBehaviour
  106. {
  107. public static PathfindingScheduler Instance { get; private set; }
  108. [Header("Threading")]
  109. [Tooltip("How many pathfinding jobs may run simultaneously. Match to logical CPU cores - 2.")]
  110. [SerializeField] private int maxConcurrentJobs = 6;
  111. [Header("Budget")]
  112. [Tooltip("Maximum path results to dispatch to agents per Unity frame. Prevents main-thread spikes.")]
  113. [SerializeField] private int resultsPerFrame = 50;
  114. // Pending requests waiting for a worker
  115. private readonly ConcurrentQueue<PathRequest> _pendingQueue = new();
  116. // Completed results ready to dispatch on the main thread
  117. private readonly ConcurrentQueue<(Action<List<Vector2Int>> callback, List<Vector2Int> result)> _resultQueue = new();
  118. // Counts active background jobs so we don't exceed the limit
  119. private int _activeJobs = 0;
  120. // Read-only copy of maze data shared across threads (set once after maze generation)
  121. private MazeData _maze;
  122. // ---- Snapshot arrays copied from MazeData for thread-safe read access ----
  123. private bool[,] _walkable; // [x, y] → is walkable
  124. private int _mazeWidth, _mazeHeight;
  125. // Room tile lookup (room ID per tile, -1 = no room)
  126. private int[,] _roomIdMap;
  127. // Rooms snapshot (read-only after bake)
  128. private MazeRoom[] _rooms;
  129. void Awake()
  130. {
  131. if (Instance != null && Instance != this) { Destroy(gameObject); return; }
  132. Instance = this;
  133. }
  134. /// <summary>
  135. /// Must be called once the maze is fully generated before any agents run.
  136. /// Bakes thread-safe copies of maze data.
  137. /// </summary>
  138. public void BakeMaze(MazeData maze)
  139. {
  140. _maze = maze;
  141. _mazeWidth = maze.Width;
  142. _mazeHeight = maze.Height;
  143. _walkable = new bool[_mazeWidth, _mazeHeight];
  144. _roomIdMap = new int[_mazeWidth, _mazeHeight];
  145. for (int x = 0; x < _mazeWidth; x++)
  146. for (int y = 0; y < _mazeHeight; y++)
  147. {
  148. _walkable[x, y] = maze.IsWalkable(x, y);
  149. _roomIdMap[x, y] = -1;
  150. }
  151. // Build room-id map
  152. _rooms = maze.Rooms.ToArray();
  153. foreach (var room in _rooms)
  154. for (int x = room.MinX; x <= room.MaxX; x++)
  155. for (int y = room.MinY; y <= room.MaxY; y++)
  156. if (x >= 0 && y >= 0 && x < _mazeWidth && y < _mazeHeight)
  157. _roomIdMap[x, y] = room.Id;
  158. Debug.Log($"[PathfindingScheduler] Baked {_mazeWidth}x{_mazeHeight} maze, {_rooms.Length} rooms, maxJobs={maxConcurrentJobs}");
  159. }
  160. /// <summary>
  161. /// Enqueue a pathfinding request. The callback fires on the main thread.
  162. /// </summary>
  163. public void RequestPath(PathRequest request)
  164. {
  165. _pendingQueue.Enqueue(request);
  166. }
  167. void Update()
  168. {
  169. if (_maze == null) return;
  170. // Dispatch pending requests to worker threads
  171. while (_activeJobs < maxConcurrentJobs && _pendingQueue.TryDequeue(out var req))
  172. {
  173. Interlocked.Increment(ref _activeJobs);
  174. Task.Run(() => ProcessRequest(req));
  175. }
  176. // Deliver completed results back on the main thread
  177. int dispatched = 0;
  178. while (dispatched < resultsPerFrame && _resultQueue.TryDequeue(out var result))
  179. {
  180. try { result.callback?.Invoke(result.result); }
  181. catch (Exception e) { Debug.LogException(e); }
  182. dispatched++;
  183. }
  184. }
  185. // -----------------------------------------------------------------------
  186. // Worker thread methods — NO Unity API calls allowed here
  187. // -----------------------------------------------------------------------
  188. private void ProcessRequest(PathRequest req)
  189. {
  190. try
  191. {
  192. List<Vector2Int> path;
  193. if (req.IsHallwayMode)
  194. path = FindPathHallway(req.Start, req.Goal);
  195. else if (req.RoomContext != null)
  196. path = FindPathInRoom(req.Start, req.Goal, req.RoomContext);
  197. else
  198. path = FindPathHallway(req.Start, req.Goal);
  199. _resultQueue.Enqueue((req.Callback, path));
  200. }
  201. catch (Exception e)
  202. {
  203. // Log safely; Unity's Debug.Log is thread-safe for logging
  204. Debug.LogException(e);
  205. _resultQueue.Enqueue((req.Callback, new List<Vector2Int>()));
  206. }
  207. finally
  208. {
  209. Interlocked.Decrement(ref _activeJobs);
  210. }
  211. }
  212. /// <summary>
  213. /// Thread-safe A* within a room (limited to room tiles + 1-tile boundary).
  214. /// Uses a min-heap open set instead of a linear list → O(n log n) vs O(n²).
  215. /// </summary>
  216. private List<Vector2Int> FindPathInRoom(Vector2Int start, Vector2Int goal, MazeRoom room)
  217. {
  218. var openSet = new MinHeap<Vector2Int>();
  219. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  220. var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
  221. float h0 = Heuristic(start, goal);
  222. openSet.Enqueue(start, h0);
  223. const int maxIter = 1000;
  224. int iter = 0;
  225. while (openSet.Count > 0 && iter++ < maxIter)
  226. {
  227. Vector2Int current = openSet.Dequeue();
  228. if (current == goal)
  229. return ReconstructPath(cameFrom, current);
  230. foreach (var nb in GetRoomNeighbors(current, room))
  231. {
  232. float tentG = gScore[current] + 1f;
  233. if (!gScore.TryGetValue(nb, out float existingG) || tentG < existingG)
  234. {
  235. cameFrom[nb] = current;
  236. gScore[nb] = tentG;
  237. float f = tentG + Heuristic(nb, goal);
  238. if (openSet.Contains(nb, out _))
  239. openSet.UpdatePriority(nb, f);
  240. else
  241. openSet.Enqueue(nb, f);
  242. }
  243. }
  244. }
  245. return new List<Vector2Int>();
  246. }
  247. /// <summary>
  248. /// Thread-safe A* through hallways (not room-constrained).
  249. /// Used when agent is navigating open corridors toward a target tile.
  250. /// </summary>
  251. private List<Vector2Int> FindPathHallway(Vector2Int start, Vector2Int goal)
  252. {
  253. var openSet = new MinHeap<Vector2Int>();
  254. var cameFrom = new Dictionary<Vector2Int, Vector2Int>();
  255. var gScore = new Dictionary<Vector2Int, float> { [start] = 0f };
  256. openSet.Enqueue(start, Heuristic(start, goal));
  257. const int maxIter = 5000;
  258. int iter = 0;
  259. while (openSet.Count > 0 && iter++ < maxIter)
  260. {
  261. Vector2Int current = openSet.Dequeue();
  262. if (current == goal)
  263. return ReconstructPath(cameFrom, current);
  264. foreach (var nb in GetWalkableNeighbors(current))
  265. {
  266. float tentG = gScore[current] + 1f;
  267. if (!gScore.TryGetValue(nb, out float existingG) || tentG < existingG)
  268. {
  269. cameFrom[nb] = current;
  270. gScore[nb] = tentG;
  271. float f = tentG + Heuristic(nb, goal);
  272. if (openSet.Contains(nb, out _))
  273. openSet.UpdatePriority(nb, f);
  274. else
  275. openSet.Enqueue(nb, f);
  276. }
  277. }
  278. }
  279. return new List<Vector2Int>();
  280. }
  281. // ---- Thread-safe helpers (access only _walkable / _roomIdMap arrays) ----
  282. private static readonly Vector2Int[] _dirs = {
  283. new(1,0), new(-1,0), new(0,1), new(0,-1)
  284. };
  285. private List<Vector2Int> GetWalkableNeighbors(Vector2Int pos)
  286. {
  287. var result = new List<Vector2Int>(4);
  288. foreach (var d in _dirs)
  289. {
  290. int nx = pos.x + d.x, ny = pos.y + d.y;
  291. if (nx >= 0 && ny >= 0 && nx < _mazeWidth && ny < _mazeHeight && _walkable[nx, ny])
  292. result.Add(new Vector2Int(nx, ny));
  293. }
  294. return result;
  295. }
  296. private List<Vector2Int> GetRoomNeighbors(Vector2Int pos, MazeRoom room)
  297. {
  298. var result = new List<Vector2Int>(4);
  299. foreach (var d in _dirs)
  300. {
  301. int nx = pos.x + d.x, ny = pos.y + d.y;
  302. if (nx < 0 || ny < 0 || nx >= _mazeWidth || ny >= _mazeHeight) continue;
  303. if (!_walkable[nx, ny]) continue;
  304. bool inRoom = room.Contains(nx, ny);
  305. bool nearBoundary = nx == room.MinX - 1 || nx == room.MaxX + 1 ||
  306. ny == room.MinY - 1 || ny == room.MaxY + 1;
  307. if (inRoom || nearBoundary)
  308. result.Add(new Vector2Int(nx, ny));
  309. }
  310. return result;
  311. }
  312. private static float Heuristic(Vector2Int a, Vector2Int b)
  313. => Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y);
  314. private static List<Vector2Int> ReconstructPath(Dictionary<Vector2Int, Vector2Int> cameFrom, Vector2Int current)
  315. {
  316. var path = new List<Vector2Int>();
  317. while (cameFrom.TryGetValue(current, out var prev))
  318. {
  319. path.Add(current);
  320. current = prev;
  321. }
  322. path.Add(current); // start node
  323. path.Reverse();
  324. return path;
  325. }
  326. }