460 lines
16 KiB
C#

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 {
/// <summary>
/// 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 cref="AstarPath.navmeshUpdates"/>
/// See: <see cref="NavmeshBase.enableNavmeshCutting"/>
/// </summary>
[System.Serializable]
public class NavmeshUpdates {
/// <summary>
/// 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 <see cref="ForceUpdate"/>.
///
/// <code>
/// // 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();
/// </code>
///
/// You can also find this in the AstarPath inspector under Settings.
/// [Open online documentation to see images]
/// </summary>
public float updateInterval;
internal AstarPath astar;
List<NavmeshUpdateSettings> listeners = new List<NavmeshUpdateSettings>();
/// <summary>Last time navmesh cuts were applied</summary>
float lastUpdateTime = float.NegativeInfinity;
/// <summary>Stores navmesh cutting related data for a single graph</summary>
// 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<NavmeshClipper> clipperLookup;
public TileLayout tileLayout;
UnsafeBitArray dirtyTiles;
List<Vector2Int> dirtyTileCoordinates = new List<Vector2Int>();
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<NavmeshClipper>(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();
}
/// <summary>Called when the graph has been resized to a different tile count</summary>
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);
}
/// <summary>Called when a NavmeshCut or NavmeshAdd is enabled</summary>
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);
}
/// <summary>Called when a NavmeshCut or NavmeshAdd is disabled</summary>
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);
}
}
/// <summary>Discards all pending updates caused by moved or modified navmesh cuts</summary>
public void DiscardPending () {
for (int i = 0; i < listeners.Count; i++) {
listeners[i].DiscardPending();
}
}
/// <summary>Called when a NavmeshCut or NavmeshAdd is enabled</summary>
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);
}
}
/// <summary>Called when a NavmeshCut or NavmeshAdd is disabled</summary>
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);
}
/// <summary>Update is called once per frame</summary>
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();
}
/// <summary>
/// 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.
///
/// <code>
/// // 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();
/// </code>
/// </summary>
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();
}
}
}
}