using UnityEngine; using System.Collections.Generic; using UnityEngine.Profiling; using Unity.Collections; using Unity.Collections.LowLevel.Unsafe; using UnityEngine.Assertions; using Pathfinding.Collections; namespace Pathfinding.Graphs.Navmesh { /// /// Helper for navmesh cut objects. /// Responsible for keeping track of which navmesh cuts have moved and coordinating graph updates to account for those changes. /// /// See: navmeshcutting (view in online documentation for working links) /// See: /// See: /// [System.Serializable] public class NavmeshUpdates { /// /// How often to check if an update needs to be done (real seconds between checks). /// For worlds with a very large number of NavmeshCut objects, it might be bad for performance to do this check every frame. /// If you think this is a performance penalty, increase this number to check less often. /// /// For almost all games, this can be kept at 0. /// /// If negative, no updates will be done. They must be manually triggered using . /// /// /// // Check every frame (the default) /// AstarPath.active.navmeshUpdates.updateInterval = 0; /// /// // Check every 0.1 seconds /// AstarPath.active.navmeshUpdates.updateInterval = 0.1f; /// /// // Never check for changes /// AstarPath.active.navmeshUpdates.updateInterval = -1; /// // You will have to schedule updates manually using /// AstarPath.active.navmeshUpdates.ForceUpdate(); /// /// /// You can also find this in the AstarPath inspector under Settings. /// [Open online documentation to see images] /// public float updateInterval; internal AstarPath astar; List listeners = new List(); /// Last time navmesh cuts were applied float lastUpdateTime = float.NegativeInfinity; /// Stores navmesh cutting related data for a single graph // When enabled the following invariant holds: // - This class should be listening for updates to the NavmeshCut.allEnabled list // - The clipperLookup should be non-null // - The tileLayout should be valid // - The dirtyTiles array should be valid // // When disabled the following invariant holds: // - This class is not listening for updates to the NavmeshCut.allEnabled list // - The clipperLookup should be null // - The dirtyTiles array should be disposed // - dirtyTileCoordinates should be empty // public class NavmeshUpdateSettings : System.IDisposable { internal readonly NavmeshBase graph; public GridLookup clipperLookup; public TileLayout tileLayout; UnsafeBitArray dirtyTiles; List dirtyTileCoordinates = new List(); public bool attachedToGraph { get; private set; } public bool enabled => clipperLookup != null; public bool anyTilesDirty => dirtyTileCoordinates.Count > 0; void AssertEnabled () { if (!enabled) throw new System.InvalidOperationException($"This method cannot be called when the {nameof(NavmeshUpdateSettings)} is disabled"); } public NavmeshUpdateSettings(NavmeshBase graph) { this.graph = graph; dirtyTiles = new UnsafeBitArray(0, Allocator.Persistent); } public NavmeshUpdateSettings(NavmeshBase graph, TileLayout tileLayout) { this.graph = graph; if (graph.enableNavmeshCutting) SetLayout(tileLayout); } public void UpdateLayoutFromGraph () { if (enabled) ForceUpdateLayoutFromGraph(); } void ForceUpdateLayoutFromGraph () { Assert.IsNotNull(graph.GetTiles()); if (graph is NavMeshGraph navmeshGraph) { SetLayout(new TileLayout(navmeshGraph)); } else if (graph is RecastGraph recastGraph) { SetLayout(new TileLayout(recastGraph)); } } void SetLayout (TileLayout tileLayout) { Dispose(); this.tileLayout = tileLayout; clipperLookup = new GridLookup(tileLayout.tileCount); dirtyTiles = new UnsafeBitArray(tileLayout.tileCount.x*tileLayout.tileCount.y, Allocator.Persistent); graph.active.navmeshUpdates.AddListener(this); } internal void MarkTilesDirty (IntRect rect) { if (!enabled) return; rect = IntRect.Intersection(rect, new IntRect(0, 0, tileLayout.tileCount.x-1, tileLayout.tileCount.y-1)); for (int z = rect.ymin; z <= rect.ymax; z++) { for (int x = rect.xmin; x <= rect.xmax; x++) { var index = x + z * tileLayout.tileCount.x; if (!dirtyTiles.IsSet(index)) { dirtyTiles.Set(index, true); dirtyTileCoordinates.Add(new Vector2Int(x, z)); } } } } public void ReloadAllTiles () { if (!enabled) return; MarkTilesDirty(new IntRect(int.MinValue, int.MinValue, int.MaxValue, int.MaxValue)); ScheduleDirtyTilesReload(); } public void AttachToGraph () { Assert.AreNotEqual(graph.navmeshUpdateData, this); if (graph.navmeshUpdateData != null) { graph.navmeshUpdateData.Dispose(); graph.navmeshUpdateData.attachedToGraph = false; } graph.navmeshUpdateData = this; attachedToGraph = true; } public void Enable () { if (enabled) throw new System.InvalidOperationException("Already enabled"); ForceUpdateLayoutFromGraph(); ReloadAllTiles(); } public void Disable () { if (!enabled) return; clipperLookup.Clear(); ReloadAllTiles(); // Reload all tiles immediately. // Disabling navmesh cutting is typically only done in the editor, so performance is not as critical. graph.active.FlushWorkItems(); Dispose(); } public void Dispose () { clipperLookup = null; if (dirtyTiles.IsCreated) dirtyTiles.Dispose(); dirtyTiles = default; if (graph.active != null) graph.active.navmeshUpdates.RemoveListener(this); } public void DiscardPending () { if (!enabled) return; for (int j = 0; j < NavmeshClipper.allEnabled.Count; j++) { var cut = NavmeshClipper.allEnabled[j]; var root = clipperLookup.GetRoot(cut); if (root != null) cut.NotifyUpdated(root); } dirtyTileCoordinates.Clear(); dirtyTiles.Clear(); } /// Called when the graph has been resized to a different tile count public void OnResized (IntRect newTileBounds, TileLayout tileLayout) { if (!enabled) return; clipperLookup.Resize(newTileBounds); this.tileLayout = tileLayout; var characterRadius = graph.NavmeshCuttingCharacterRadius; // New tiles may have been created when resizing. If a cut was on the edge of the graph bounds, // it may intersect with the new tiles and we will need to recalculate them in that case. var allCuts = clipperLookup.AllItems; for (var cut = allCuts; cut != null; cut = cut.next) { var newGraphSpaceBounds = cut.obj.GetBounds(tileLayout.transform, characterRadius); var newTouchingTiles = tileLayout.GetTouchingTilesInGraphSpace(newGraphSpaceBounds); if (cut.previousBounds != newTouchingTiles) { clipperLookup.Dirty(cut.obj); clipperLookup.Move(cut.obj, newTouchingTiles); } } // Transform dirty tile coordinates to be relative to the new tile bounds for (int i = 0; i < dirtyTileCoordinates.Count; i++) { var p = dirtyTileCoordinates[i]; if (newTileBounds.Contains(p.x, p.y)) { // Still dirty, but translate it to the new tile coordinates dirtyTileCoordinates[i] = new Vector2Int(p.x - newTileBounds.xmin, p.y - newTileBounds.ymin); } else { // Not in the new bounds, remove it dirtyTileCoordinates.RemoveAtSwapBack(i); i--; } } #if MODULE_COLLECTIONS_2_1_0_OR_NEWER this.dirtyTiles.Resize(newTileBounds.Width * newTileBounds.Height); this.dirtyTiles.Clear(); #else this.dirtyTiles.Dispose(); this.dirtyTiles = new UnsafeBitArray(newTileBounds.Width * newTileBounds.Height, Allocator.Persistent); #endif for (int i = 0; i < dirtyTileCoordinates.Count; i++) { this.dirtyTiles.Set(dirtyTileCoordinates[i].x + dirtyTileCoordinates[i].y * newTileBounds.Width, true); } } public void Dirty (NavmeshClipper obj) { // If we have no clipperLookup then we can ignore this. If we would later create a clipperLookup the object would be automatically dirtied anyway. if (enabled) clipperLookup.Dirty(obj); } /// Called when a NavmeshCut or NavmeshAdd is enabled public void AddClipper (NavmeshClipper obj) { AssertEnabled(); if (!obj.graphMask.Contains((int)graph.graphIndex)) return; var characterRadius = graph.NavmeshCuttingCharacterRadius; var graphSpaceBounds = obj.GetBounds(tileLayout.transform, characterRadius); var touchingTiles = tileLayout.GetTouchingTilesInGraphSpace(graphSpaceBounds); clipperLookup.Add(obj, touchingTiles); } /// Called when a NavmeshCut or NavmeshAdd is disabled public void RemoveClipper (NavmeshClipper obj) { AssertEnabled(); var root = clipperLookup.GetRoot(obj); if (root != null) { MarkTilesDirty(root.previousBounds); clipperLookup.Remove(obj); } } public void ScheduleDirtyTilesReload () { AssertEnabled(); if (dirtyTileCoordinates.Count == 0) return; var size = this.tileLayout.tileCount; graph.active.AddWorkItem(ctx => { ctx.PreUpdate(); ReloadDirtyTilesImmediately(); }); } public void ReloadDirtyTilesImmediately () { if (!enabled || dirtyTileCoordinates.Count == 0) return; var data = RecastBuilder.CutTiles(graph, clipperLookup, tileLayout).Schedule(dirtyTileCoordinates); data.Complete(); var result = data.GetValue(); graph.StartBatchTileUpdate(); if (!result.tileMeshes.tileMeshes.IsCreated) { // The cut job output nothing, indicating that no cuts are affecting the tiles. // We can just replace the tiles with the non-cut tiles. for (int i = 0; i < dirtyTileCoordinates.Count; i++) { var tile = graph.GetTile(dirtyTileCoordinates[i].x, dirtyTileCoordinates[i].y); if (tile.isCut) { graph.ReplaceTilePostCut(tile.x, tile.z, tile.preCutVertsInTileSpace, tile.preCutTris, tile.preCutTags, true, true); } else { // Tile is not cut, and no new cuts are affecting it. Skip it. } } } else { for (int i = 0; i < result.tileMeshes.tileMeshes.Length; i++) { var tileMesh = result.tileMeshes.tileMeshes[i]; graph.ReplaceTilePostCut(dirtyTileCoordinates[i].x, dirtyTileCoordinates[i].y, tileMesh.verticesInTileSpace, tileMesh.triangles, tileMesh.tags, true, true); } } result.Dispose(); graph.EndBatchTileUpdate(); dirtyTileCoordinates.Clear(); dirtyTiles.Clear(); } } internal void OnEnable () { // Needs to reset the time if we are using Play Mode Edit Options that do not reset the scene or reload the domain when entering play mode lastUpdateTime = float.NegativeInfinity; Profiler.BeginSample("Refresh navmesh cut enabled list"); NavmeshClipper.RefreshEnabledList(); Profiler.EndSample(); NavmeshClipper.AddEnableCallback(HandleOnEnableCallback, HandleOnDisableCallback); } internal void OnDisable () { NavmeshClipper.RemoveEnableCallback(HandleOnEnableCallback, HandleOnDisableCallback); } public void ForceUpdateAround (NavmeshClipper clipper) { for (int i = 0; i < listeners.Count; i++) { listeners[i].Dirty(clipper); } } /// Discards all pending updates caused by moved or modified navmesh cuts public void DiscardPending () { for (int i = 0; i < listeners.Count; i++) { listeners[i].DiscardPending(); } } /// Called when a NavmeshCut or NavmeshAdd is enabled void HandleOnEnableCallback (NavmeshClipper obj) { for (int i = 0; i < listeners.Count; i++) { // Add the clipper to the individual graphs. Note that this automatically marks the clipper as dirty for that particular graph. listeners[i].AddClipper(obj); } } /// Called when a NavmeshCut or NavmeshAdd is disabled void HandleOnDisableCallback (NavmeshClipper obj) { for (int i = 0; i < listeners.Count; i++) { listeners[i].RemoveClipper(obj); } lastUpdateTime = float.NegativeInfinity; } void AddListener (NavmeshUpdateSettings listener) { #if UNITY_EDITOR if (listeners.Contains(listener)) throw new System.ArgumentException("Trying to register a listener multiple times."); #endif listeners.Add(listener); for (int i = 0; i < NavmeshClipper.allEnabled.Count; i++) listener.AddClipper(NavmeshClipper.allEnabled[i]); } void RemoveListener (NavmeshUpdateSettings listener) { listeners.Remove(listener); } /// Update is called once per frame internal void Update () { if (astar.isScanning) return; Profiler.BeginSample("Navmesh cutting"); bool anyTilesDirty = false; RefreshEnabledState(); for (int i = 0; i < listeners.Count; i++) { // Tiles can have already been dirtied by, for example, navmesh cuts being disabled anyTilesDirty |= listeners[i].anyTilesDirty; } if ((updateInterval >= 0 && Time.realtimeSinceStartup - lastUpdateTime > updateInterval) || anyTilesDirty) { ScheduleTileUpdates(); } Profiler.EndSample(); } /// /// Checks all NavmeshCut instances and updates graphs if needed. /// Note: This schedules updates for all necessary tiles to happen as soon as possible. /// The pathfinding threads will continue to calculate the paths that they were calculating when this function /// was called and then they will be paused and the graph updates will be carried out (this may be several frames into the /// future and the graph updates themselves may take several frames to complete). /// If you want to force all navmesh cutting to be completed in a single frame call this method /// and immediately after call AstarPath.FlushWorkItems. /// /// /// // Schedule pending updates to be done as soon as the pathfinding threads /// // are done with what they are currently doing. /// AstarPath.active.navmeshUpdates.ForceUpdate(); /// // Block until the updates have finished /// AstarPath.active.FlushGraphUpdates(); /// /// public void ForceUpdate () { RefreshEnabledState(); ScheduleTileUpdates(); } void RefreshEnabledState () { var graphs = astar.graphs; for (int i = 0; i < graphs.Length; i++) { var graph = graphs[i]; if (graph is NavmeshBase navmesh) { var shouldBeEnabled = navmesh.enableNavmeshCutting && navmesh.isScanned; if (navmesh.navmeshUpdateData.enabled != shouldBeEnabled) { if (shouldBeEnabled) { navmesh.navmeshUpdateData.Enable(); } else { navmesh.navmeshUpdateData.Disable(); } } } } } void ScheduleTileUpdates () { lastUpdateTime = Time.realtimeSinceStartup; foreach (var handler in listeners) { Assert.IsTrue(handler.enabled); if (!handler.attachedToGraph) continue; // Get all navmesh cuts in the scene var allCuts = handler.clipperLookup.AllItems; if (!handler.anyTilesDirty) { bool any = false; // Check if any navmesh cuts need updating for (var cut = allCuts; cut != null; cut = cut.next) { if (cut.obj.RequiresUpdate(cut)) { any = true; break; } } // Nothing needs to be done for now if (!any) continue; } var characterRadius = handler.graph.NavmeshCuttingCharacterRadius; // Reload all bounds touching the previous bounds and current bounds // of navmesh cuts that have moved or changed in some other way for (var cut = allCuts; cut != null; cut = cut.next) { if (cut.obj.RequiresUpdate(cut)) { // Make sure the tile where it was is updated handler.MarkTilesDirty(cut.previousBounds); var newGraphSpaceBounds = cut.obj.GetBounds(handler.tileLayout.transform, characterRadius); var newTouchingTiles = handler.tileLayout.GetTouchingTilesInGraphSpace(newGraphSpaceBounds); handler.clipperLookup.Move(cut.obj, newTouchingTiles); handler.MarkTilesDirty(newTouchingTiles); // Notify the navmesh cut that it has been updated in this graph // This will cause RequiresUpdate to return false // until it is changed again. cut.obj.NotifyUpdated(cut); } } handler.ScheduleDirtyTilesReload(); } } } }