using System.Collections.Generic; using UnityEngine; namespace Pathfinding { using Pathfinding.Drawing; using Pathfinding.Graphs.Navmesh; using Pathfinding.Jobs; using Pathfinding.Serialization; using Pathfinding.Util; using Unity.Jobs; /// /// Stores a set of navmesh tiles which can be placed on a recast graph. /// /// This component is used to store chunks of a to a file and then be able to efficiently load them and place them on an existing recast graph. /// A typical use case is if you have a procedurally generated level consisting of multiple rooms, and scanning the graph after the level has been generated /// is too expensive. In this scenario, each room can have its own NavmeshPrefab component which stores the navmesh for just that room, and then when the /// level is generated all the NavmeshPrefab components will load their tiles and place them on the recast graph, joining them together at the seams. /// /// Since this component works on tiles, the size of a NavmeshPrefab must be a multiple of the graph's tile size. /// The tile size of a recast graph is determined by multiplying the with the tile size in voxels (). /// When a NavmeshPrefab is placed on a recast graph, it will load the tiles into the closest spot (snapping the position and rotation). /// The NavmeshPrefab will even resize the graph to make it larger in case you want to place a NavmeshPrefab outside the existing bounds of the graph. /// /// Usage /// /// - Attach a NavmeshPrefab component to a GameObject (typically a prefab) that you want to store the navmesh for. /// - Make sure you have a RecastGraph elsewhere in the scene with the same settings that you use for the game. /// - Adjust the bounding box to fit your game object. The bounding box should be a multiple of the tile size of the recast graph. /// - In the inspector, click the "Scan" button to scan the graph and store the navmesh as a file, referenced by the NavmeshPrefab component. /// - Make sure the rendered navmesh looks ok in the scene view. /// - In your game, instantiate a prefab with the NavmeshComponent. It will automatically load its stored tiles and place them on the first recast graph in the scene. /// /// If you have multiple recast graphs you may not want it to always use the first recast graph. /// In that case you can set the field to false and call the method manually. /// /// Accounting for borders /// /// When scanning a recast graph (and by extension a NavmeshPrefab), a margin is always added around parts of the graph the agent cannot traverse. /// This can become problematic when scanning individual chunks separate from the rest of the world, because each one will have a small border of unwalkable space. /// The result is that when you place them on a recast graph, they will not be able to connect to each other. /// [Open online documentation to see images] /// One way to solve this is to scan the prefab together with a mesh that is slightly larger than the prefab, extending the walkable surface enough /// so that no border is added. In the image below, this mesh is displayed in white. It can be convenient to make this an invisible collider on the prefab /// that is excluded from physics, but is included in the graph's rasterization layer mask. /// [Open online documentation to see images] /// Now that the border has been removed, the chunks can be placed next to each other and be able to connect. /// [Open online documentation to see images] /// /// Loading tiles into a graph /// /// If is true, the tiles will be loaded into the first recast graph in the scene when the game starts. /// If the recast graph is not scanned, it will be initialized with empty tiles and then the tiles will be loaded into it. /// So if your world is made up entirely of NavmeshPrefabs, you can skip scanning for performance by setting A* Inspector -> Settings -> Scan On Awake to false. /// /// You can also apply a NavmeshPrefab to a graph manually by calling the method. /// /// See: /// See: /// [AddComponentMenu("Pathfinding/Navmesh Prefab")] [HelpURL("https://arongranberg.com/astar/documentation/stable/navmeshprefab.html")] public class NavmeshPrefab : VersionedMonoBehaviour { /// Reference to the serialized tile data public TextAsset serializedNavmesh; /// /// If true, the tiles stored in this prefab will be loaded and applied to the first recast graph in the scene when this component is enabled. /// If false, you will have to call the method manually. /// /// If this component is disabled and then enabled again, the tiles will be reloaded. /// public bool applyOnStart = true; /// /// If true, the tiles that this prefab loaded into the graph will be removed when this component is disabled or destroyed. /// If false, the tiles will remain in the graph. /// public bool removeTilesWhenDisabled = true; /// /// Bounding box for the navmesh to be stored in this prefab. /// Should be a multiple of the tile size of the associated recast graph. /// /// See: /// See: /// public Bounds bounds = new Bounds(Vector3.zero, new Vector3(10, 10, 10)); bool startHasRun = false; protected override void Reset () { base.Reset(); AstarPath.FindAstarPath(); if (AstarPath.active != null && AstarPath.active.data.recastGraph != null) { var graph = AstarPath.active.data.recastGraph; // Make the default bounds be 1x1 tiles in the graph bounds = new Bounds(Vector3.zero, new Vector3(graph.TileWorldSizeX, graph.forcedBoundsSize.y, graph.TileWorldSizeZ)); } } #if UNITY_EDITOR public override void DrawGizmos () { using (Draw.WithMatrix(Matrix4x4.TRS(transform.position, transform.rotation, Vector3.one))) { Draw.WireBox(bounds.center, bounds.size); } if (!Application.isPlaying && serializedNavmesh != null) { var path = UnityEditor.AssetDatabase.GetAssetPath(serializedNavmesh); var lastEditTime = System.IO.File.GetLastWriteTimeUtc(Application.dataPath + "/../" + path); lastEditTime.ToBinary(); // Hash the metadata to avoid somewhat expensive deserialization and drawing every frame. var hasher = new Pathfinding.Drawing.DrawingData.Hasher(); hasher.Add(lastEditTime); hasher.Add(transform.position); hasher.Add(transform.rotation); hasher.Add(bounds); // Draw a new mesh if the metadata has changed if (!Pathfinding.Drawing.DrawingManager.instance.gizmos.Draw(hasher)) { var builder = Pathfinding.Drawing.DrawingManager.GetBuilder(hasher); var tileMeshes = TileMeshes.Deserialize(serializedNavmesh.bytes); var center = transform.position + transform.rotation * bounds.center; var corner = center - transform.rotation*bounds.extents; var tileWorldSize = tileMeshes.tileWorldSize; var graphToWorldSpace = Matrix4x4.TRS(corner, transform.rotation, Vector3.one); var vertexCount = 0; var trisCount = 0; for (int i = 0; i < tileMeshes.tileMeshes.Length; i++) { vertexCount += tileMeshes.tileMeshes[i].verticesInTileSpace.Length; trisCount += tileMeshes.tileMeshes[i].triangles.Length; } var colors = Util.ArrayPool.Claim(vertexCount); var vertices = Util.ArrayPool.Claim(vertexCount); var triangles = Util.ArrayPool.Claim(trisCount); vertexCount = 0; trisCount = 0; using (builder.WithColor(AstarColor.SolidColor)) { for (int z = 0; z < tileMeshes.tileRect.Height; z++) { for (int x = 0; x < tileMeshes.tileRect.Width; x++) { var tile = tileMeshes.tileMeshes[x + z*tileMeshes.tileRect.Width]; var tileToWorldSpace = graphToWorldSpace * Matrix4x4.Translate(new Vector3(x * tileWorldSize.x, 0, z * tileWorldSize.y)); var startVertex = vertexCount; for (int j = 0; j < tile.triangles.Length; trisCount++, j++) { triangles[trisCount] = tile.triangles[j] + startVertex; } for (int j = 0; j < tile.verticesInTileSpace.Length; vertexCount++, j++) { colors[vertexCount] = AstarColor.SolidColor; vertices[vertexCount] = tileToWorldSpace.MultiplyPoint3x4((Vector3)tile.verticesInTileSpace[j]); } for (int i = 0; i < tile.triangles.Length; i += 3) { builder.Line(vertices[startVertex + tile.triangles[i+0]], vertices[startVertex + tile.triangles[i+1]]); builder.Line(vertices[startVertex + tile.triangles[i+1]], vertices[startVertex + tile.triangles[i+2]]); builder.Line(vertices[startVertex + tile.triangles[i+2]], vertices[startVertex + tile.triangles[i+0]]); } } } } builder.SolidMesh(vertices, triangles, colors, vertexCount, trisCount); Util.ArrayPool.Release(ref colors); Util.ArrayPool.Release(ref vertices); Util.ArrayPool.Release(ref triangles); builder.Dispose(); } } } #endif /// /// Moves and rotates this object so that it is aligned with tiles in the first recast graph in the scene /// /// See: SnapToClosestTileAlignment(RecastGraph) /// [ContextMenu("Snap to closest tile alignment")] public void SnapToClosestTileAlignment () { AstarPath.FindAstarPath(); if (AstarPath.active != null && AstarPath.active.data.recastGraph != null) { SnapToClosestTileAlignment(AstarPath.active.data.recastGraph); } } /// /// Applies the navmesh stored in this prefab to the first recast graph in the scene. /// /// See: for more details. /// [ContextMenu("Apply here")] public void Apply () { AstarPath.FindAstarPath(); if (AstarPath.active != null && AstarPath.active.data.recastGraph != null) { var graph = AstarPath.active.data.recastGraph; Apply(graph); } } /// Moves and rotates this object so that it is aligned with tiles in the given graph public void SnapToClosestTileAlignment (RecastGraph graph) { // Calculate a new tile layout, because the graph may not be scanned yet (especially if this code runs outside of play mode) var tileLayout = new TileLayout(graph); SnapToGraph(tileLayout, transform.position, transform.rotation, bounds, out IntRect tileRect, out int snappedRotation, out float yOffset); var graphSpaceBounds = tileLayout.GetTileBoundsInGraphSpace(tileRect.xmin, tileRect.ymin, tileRect.Width, tileRect.Height); var centerInGraphSpace = new Vector3(graphSpaceBounds.center.x, yOffset, graphSpaceBounds.center.z); #if UNITY_EDITOR if (!Application.isPlaying) UnityEditor.Undo.RecordObject(transform, "Snap to closest tile alignment"); #endif transform.rotation = Quaternion.Euler(graph.rotation) * Quaternion.Euler(0, snappedRotation * 90, 0); transform.position = tileLayout.transform.Transform(centerInGraphSpace) + transform.rotation*(-bounds.center + new Vector3(0, bounds.extents.y, 0)); #if UNITY_EDITOR if (!Application.isPlaying) UnityEditor.EditorUtility.SetDirty(transform); #endif } /// /// Rounds the size of the to the closest multiple of the tile size in the graph, ensuring that the bounds cover at least 1x1 tiles. /// The new bounds has the same center and size along the y-axis. /// public void SnapSizeToClosestTileMultiple (RecastGraph graph) { this.bounds = SnapSizeToClosestTileMultiple(graph, this.bounds); } /// Start is called before the first frame update void Start () { startHasRun = true; if (applyOnStart && serializedNavmesh != null && AstarPath.active != null && AstarPath.active.data.recastGraph != null) Apply(AstarPath.active.data.recastGraph); } void OnEnable () { if (startHasRun && applyOnStart && serializedNavmesh != null && AstarPath.active != null && AstarPath.active.data.recastGraph != null) Apply(AstarPath.active.data.recastGraph); } void OnDisable () { if (removeTilesWhenDisabled && serializedNavmesh != null && AstarPath.active != null) { var pos = transform.position; var rot = transform.rotation; AstarPath.active.AddWorkItem(ctx => { var graph = AstarPath.active.data.recastGraph; if (graph != null) { SnapToGraph(new TileLayout(graph), pos, rot, bounds, out IntRect tileRect, out int rotation, out float yOffset); graph.ClearTiles(tileRect); } }); } } /// /// Rounds the size of the bounds to the closest multiple of the tile size in the graph, ensuring that the bounds cover at least 1x1 tiles. /// The returned bounds has the same center and size along the y-axis as the input. /// public static Bounds SnapSizeToClosestTileMultiple (RecastGraph graph, Bounds bounds) { var tileSize = Mathf.Max(graph.editorTileSize * graph.cellSize, 0.001f); var tiles = new Vector2(bounds.size.x / tileSize, bounds.size.z / tileSize); var roundedTiles = new Int2(Mathf.Max(1, Mathf.RoundToInt(tiles.x)), Mathf.Max(1, Mathf.RoundToInt(tiles.y))); return new Bounds( bounds.center, new Vector3( roundedTiles.x * tileSize, bounds.size.y, roundedTiles.y * tileSize ) ); } public static void SnapToGraph (TileLayout tileLayout, Vector3 position, Quaternion rotation, Bounds bounds, out IntRect tileRect, out int snappedRotation, out float yOffset) { var rotInGraphSpace = tileLayout.transform.InverseTransformVector(rotation * Vector3.right); // Snap to increments of 90 degrees snappedRotation = -Mathf.RoundToInt(Mathf.Atan2(rotInGraphSpace.z, rotInGraphSpace.x) / (0.5f*Mathf.PI)); var snappedRotationQ = Quaternion.Euler(0, snappedRotation * 90, 0); var localToGraph = tileLayout.transform.inverseMatrix * Matrix4x4.TRS(position + snappedRotationQ * bounds.center, snappedRotationQ, Vector3.one); var cornerInGraphSpace1 = localToGraph.MultiplyPoint3x4(-bounds.extents); var cornerInGraphSpace2 = localToGraph.MultiplyPoint3x4(bounds.extents); var minInGraphSpace = Vector3.Min(cornerInGraphSpace1, cornerInGraphSpace2); var tileCoordinatesF = Vector3.Scale(minInGraphSpace, new Vector3(1.0f/tileLayout.TileWorldSizeX, 1, 1.0f/tileLayout.TileWorldSizeZ)); var tileCoordinates = new Int2(Mathf.RoundToInt(tileCoordinatesF.x), Mathf.RoundToInt(tileCoordinatesF.z)); var boundsSizeInGraphSpace = new Vector2(bounds.size.x, bounds.size.z); if (((snappedRotation % 2) + 2) % 2 == 1) Util.Memory.Swap(ref boundsSizeInGraphSpace.x, ref boundsSizeInGraphSpace.y); var w = Mathf.Max(1, Mathf.RoundToInt(boundsSizeInGraphSpace.x / tileLayout.TileWorldSizeX)); var h = Mathf.Max(1, Mathf.RoundToInt(boundsSizeInGraphSpace.y / tileLayout.TileWorldSizeZ)); tileRect = new IntRect( tileCoordinates.x, tileCoordinates.y, tileCoordinates.x + w - 1, tileCoordinates.y + h - 1 ); yOffset = minInGraphSpace.y; } /// /// Applies the navmesh stored in this prefab to the given graph. /// The loaded tiles will be placed at the closest valid spot to this object's current position. /// Some rounding may occur because the tiles need to be aligned to the graph's tile boundaries. /// /// If the recast graph is not scanned, it will be initialized with empty tiles and then the tiles in this prefab will be loaded into it. /// /// If the recast graph is too small and the tiles would have been loaded out of bounds, the graph will first be resized to fit. /// If you have a large graph, this resizing can be a somewhat expensive operation. /// /// See: /// public void Apply (RecastGraph graph) { if (serializedNavmesh == null) throw new System.InvalidOperationException("Cannot Apply NavmeshPrefab because no serialized data has been set"); AstarPath.active.AddWorkItem(() => { UnityEngine.Profiling.Profiler.BeginSample("NavmeshPrefab.Apply"); SnapToGraph(new TileLayout(graph), transform.position, transform.rotation, bounds, out IntRect tileRect, out int rotation, out float yOffset); var tileMeshes = TileMeshes.Deserialize(serializedNavmesh.bytes); tileMeshes.Rotate(rotation); if (tileMeshes.tileRect.Width != tileRect.Width || tileMeshes.tileRect.Height != tileRect.Height) { throw new System.Exception("NavmeshPrefab has been scanned with a different size than it is right now (or with a different graph). Expected to find " + tileRect.Width + "x" + tileRect.Height + " tiles, but found " + tileMeshes.tileRect.Width + "x" + tileMeshes.tileRect.Height); } tileMeshes.tileRect = tileRect; graph.ReplaceTiles(tileMeshes, yOffset); UnityEngine.Profiling.Profiler.EndSample(); }); } /// Scans the navmesh using the first recast graph in the scene, and returns a serialized byte representation public byte[] Scan () { // Make sure this method works even when called in the editor outside of play mode. AstarPath.FindAstarPath(); if (AstarPath.active == null || AstarPath.active.data.recastGraph == null) throw new System.InvalidOperationException("There's no recast graph in the scene. Add one if you want to scan this navmesh prefab."); return Scan(AstarPath.active.data.recastGraph); } /// Scans the navmesh and returns a serialized byte representation public byte[] Scan (RecastGraph graph) { // Schedule the jobs asynchronously, but immediately wait for them to finish var result = ScanAsync(graph).Complete(); var data = result.data; // Dispose of all the unmanaged memory result.Dispose(); return data; } /// /// Scans the navmesh asynchronously and returns a promise of a byte representation. /// /// TODO: Maybe change this method to return a object instead? /// public Promise ScanAsync (RecastGraph graph) { var arena = new DisposeArena(); // First configure the rasterization settings by copying them from the recast graph, // but changing which region we are interested in. var tileLayout = new TileLayout( new Bounds(transform.position + transform.rotation * bounds.center, bounds.size), transform.rotation, graph.cellSize, graph.editorTileSize, graph.useTiles ); var buildSettings = RecastBuilder.BuildTileMeshes(graph, tileLayout, new IntRect(0, 0, tileLayout.tileCount.x - 1, tileLayout.tileCount.y - 1)); buildSettings.scene = this.gameObject.scene; // Schedule the jobs asynchronously var tileMeshesPromise = buildSettings.Schedule(arena); var output = new SerializedOutput { promise = tileMeshesPromise, arena = arena, }; var serializeJob = new SerializeJob { tileMeshesPromise = tileMeshesPromise, output = output, }.ScheduleManaged(tileMeshesPromise.handle); return new Promise(serializeJob, output); } public class SerializedOutput : IProgress, System.IDisposable { public Promise promise; public byte[] data; public DisposeArena arena; public float Progress => promise.Progress; public void Dispose () { // Dispose of all the unmanaged memory promise.Dispose(); arena.DisposeAll(); } } struct SerializeJob : IJob { public Promise tileMeshesPromise; public SerializedOutput output; public void Execute () { // Note: Assumes that the tileMeshesPromise has already completed var tileMeshes = tileMeshesPromise.GetValue(); // Serialize the data to a byte array output.data = tileMeshes.tileMeshes.ToManaged().Serialize(); } } #if UNITY_EDITOR /// /// Saves the given data to the field, or creates a new file if none exists. /// /// A new file will be created if is null. /// If this object is part of a prefab, the file name will be based on the prefab's name. /// /// Warning: This method is only available in the editor. /// /// Warning: You should only pass valid serialized tile data to this function. /// /// See: /// See: /// public void SaveToFile (byte[] data) { string path; if (serializedNavmesh != null) { // If we already have a file, just overwrite it path = UnityEditor.AssetDatabase.GetAssetPath(serializedNavmesh); } else { // Otherwise create a new file. // If this is a prefab, base the name on the prefab's name. System.IO.Directory.CreateDirectory(Application.dataPath + "/Tiles"); var name = "tiles"; var prefabPath = UnityEditor.PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(this); if (prefabPath != null && prefabPath != "") { name = System.IO.Path.GetFileNameWithoutExtension(prefabPath); } name = name.Replace("/", "_").Replace(".", "_").Replace("__", "_"); path = UnityEditor.AssetDatabase.GenerateUniqueAssetPath("Assets/Tiles/" + name + ".bytes"); } var fullPath = Application.dataPath + "/../" + path; System.IO.File.WriteAllBytes(fullPath, data); UnityEditor.AssetDatabase.Refresh(); serializedNavmesh = UnityEditor.AssetDatabase.LoadAssetAtPath(path, typeof(TextAsset)) as TextAsset; // Required if we do this in edit mode UnityEditor.EditorUtility.SetDirty(this); } /// /// Scans the navmesh and saves it to the field. /// A new file will be created if is null. /// If this object is part of a prefab, the file name will be based on the prefab's name. /// /// Note: This method is only available in the editor. /// public void ScanAndSaveToFile () { SaveToFile(Scan()); } #endif protected override void OnUpgradeSerializedData (ref Migrations migrations, bool unityThread) { migrations.TryMigrateFromLegacyFormat(out var _); if (migrations.AddAndMaybeRunMigration(1 << 0)) { removeTilesWhenDisabled = false; } base.OnUpgradeSerializedData(ref migrations, unityThread); } } }