using System.Collections.Generic; using Pathfinding.Graphs.Navmesh.Jobs; using Pathfinding.Jobs; using Pathfinding.Pooling; using Pathfinding.Sync; using Pathfinding.Graphs.Navmesh.Voxelization.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using UnityEngine.Profiling; using UnityEngine.Assertions; namespace Pathfinding.Graphs.Navmesh { /// /// Settings for building tile meshes in a recast graph. /// /// See: for more documentation on the individual fields. /// See: /// public struct TileBuilder { public float walkableClimb; public RecastGraph.CollectionSettings collectionSettings; public RecastGraph.RelevantGraphSurfaceMode relevantGraphSurfaceMode; public RecastGraph.DimensionMode dimensionMode; public RecastGraph.BackgroundTraversability backgroundTraversability; // TODO: Don't store in struct public int tileBorderSizeInVoxels; public float walkableHeight; public float maxSlope; // TODO: Specify in world units public int characterRadiusInVoxels; public int minRegionSize; public float maxEdgeLength; public float contourMaxError; public UnityEngine.SceneManagement.Scene scene; public TileLayout tileLayout; public IntRect tileRect; public List perLayerModifications; public class TileBuilderOutput : IProgress, System.IDisposable { public NativeReference currentTileCounter; public TileMeshesUnsafe tileMeshes; #if UNITY_EDITOR public List<(UnityEngine.Object, Mesh)> meshesUnreadableAtRuntime; #endif public float Progress { get { var tileCount = tileMeshes.tileRect.Area; var currentTile = Mathf.Min(tileCount, currentTileCounter.Value); return tileCount > 0 ? currentTile / (float)tileCount : 0; // "Scanning tiles: " + currentTile + " of " + (tileCount) + " tiles..."); } } public void Dispose () { tileMeshes.Dispose(Allocator.Persistent); if (currentTileCounter.IsCreated) currentTileCounter.Dispose(); #if UNITY_EDITOR if (meshesUnreadableAtRuntime != null) ListPool<(UnityEngine.Object, Mesh)>.Release(ref meshesUnreadableAtRuntime); #endif } } public TileBuilder (RecastGraph graph, TileLayout tileLayout, IntRect tileRect) { this.tileLayout = tileLayout; this.tileRect = tileRect; // A walkableClimb higher than walkableHeight can cause issues when generating the navmesh since then it can in some cases // Both be valid for a character to walk under an obstacle and climb up on top of it (and that cannot be handled with navmesh without links) // The editor scripts also enforce this, but we enforce it here too just to be sure this.walkableClimb = Mathf.Min(graph.walkableClimb, graph.walkableHeight); this.collectionSettings = graph.collectionSettings; this.dimensionMode = graph.dimensionMode; this.backgroundTraversability = graph.backgroundTraversability; this.tileBorderSizeInVoxels = graph.TileBorderSizeInVoxels; this.walkableHeight = graph.walkableHeight; this.maxSlope = graph.maxSlope; this.characterRadiusInVoxels = graph.CharacterRadiusInVoxels; this.minRegionSize = Mathf.RoundToInt(graph.minRegionSize); this.maxEdgeLength = graph.maxEdgeLength; this.contourMaxError = graph.contourMaxError; this.relevantGraphSurfaceMode = graph.relevantGraphSurfaceMode; this.scene = graph.active.gameObject.scene; this.perLayerModifications = graph.perLayerModifications; } /// /// Number of extra voxels on each side of a tile to ensure accurate navmeshes near the tile border. /// The width of a tile is expanded by 2 times this value (1x to the left and 1x to the right) /// int TileBorderSizeInVoxels { get { return characterRadiusInVoxels + 3; } } float TileBorderSizeInWorldUnits { get { return TileBorderSizeInVoxels*tileLayout.cellSize; } } /// Get the world space bounds for all tiles, including an optional (graph space) padding around the tiles in the x and z axis public Bounds GetWorldSpaceBounds (float xzPadding = 0) { var graphSpaceBounds = tileLayout.GetTileBoundsInGraphSpace(tileRect.xmin, tileRect.ymin, tileRect.Width, tileRect.Height); graphSpaceBounds.Expand(new Vector3(2*xzPadding, 0, 2*xzPadding)); return tileLayout.transform.Transform(graphSpaceBounds); } public RecastMeshGatherer.MeshCollection CollectMeshes (Bounds bounds) { Profiler.BeginSample("Find Meshes for rasterization"); var mask = collectionSettings.layerMask; var tagMask = collectionSettings.tagMask; if (collectionSettings.collectionMode == RecastGraph.CollectionSettings.FilterMode.Layers) { tagMask = null; } else { mask = -1; } var meshGatherer = new RecastMeshGatherer(scene, bounds, collectionSettings.terrainHeightmapDownsamplingFactor, collectionSettings.layerMask, collectionSettings.tagMask, perLayerModifications, tileLayout.cellSize / collectionSettings.colliderRasterizeDetail); if (collectionSettings.rasterizeMeshes && dimensionMode == RecastGraph.DimensionMode.Dimension3D) { Profiler.BeginSample("Find meshes"); meshGatherer.CollectSceneMeshes(); Profiler.EndSample(); } Profiler.BeginSample("Find RecastNavmeshModifiers"); meshGatherer.CollectRecastNavmeshModifiers(); Profiler.EndSample(); if (collectionSettings.rasterizeTerrain && dimensionMode == RecastGraph.DimensionMode.Dimension3D) { Profiler.BeginSample("Find terrains"); // Split terrains up into meshes approximately the size of a single tile var desiredTerrainChunkSize = 0.51f * tileLayout.cellSize*(math.max(tileLayout.tileSizeInVoxels.x, tileLayout.tileSizeInVoxels.y) + 2*TileBorderSizeInVoxels); meshGatherer.CollectTerrainMeshes(collectionSettings.rasterizeTrees, desiredTerrainChunkSize); Profiler.EndSample(); } if (collectionSettings.rasterizeColliders || dimensionMode == RecastGraph.DimensionMode.Dimension2D) { Profiler.BeginSample("Find colliders"); if (dimensionMode == RecastGraph.DimensionMode.Dimension3D) { meshGatherer.CollectColliderMeshes(); } else { meshGatherer.Collect2DColliderMeshes(); } Profiler.EndSample(); } if (collectionSettings.onCollectMeshes != null) { Profiler.BeginSample("Custom mesh collection"); collectionSettings.onCollectMeshes(meshGatherer); Profiler.EndSample(); } Profiler.BeginSample("Finalizing"); var result = meshGatherer.Finalize(); Profiler.EndSample(); // Warn if no meshes were found, but only if the tile rect covers the whole graph. // If it's just a partial update, the user is probably not interested in this warning, // as it is completely normal that there are some empty tiles. if (tileRect == new IntRect(0, 0, tileLayout.tileCount.x - 1, tileLayout.tileCount.y - 1) && result.meshes.Length == 0) { Debug.LogWarning("No rasterizable objects were found contained in the layers specified by the 'mask' variables"); } Profiler.EndSample(); return result; } /// A mapping from tiles to the meshes that each tile touches public struct BucketMapping { /// All meshes that should be voxelized public NativeArray meshes; /// Indices into the array public NativeArray pointers; /// /// For each tile, the range of pointers in that correspond to that tile. /// This is a cumulative sum of the number of pointers in each bucket. /// /// Bucket i will contain pointers in the range [i > 0 ? bucketRanges[i-1] : 0, bucketRanges[i]). /// /// The length is the same as the number of tiles. /// public NativeArray bucketRanges; } /// Creates a list for every tile and adds every mesh that touches a tile to the corresponding list BucketMapping PutMeshesIntoTileBuckets (RecastMeshGatherer.MeshCollection meshCollection, IntRect tileBuckets) { var bucketCount = tileBuckets.Width*tileBuckets.Height; var buckets = new NativeList[bucketCount]; var borderExpansion = TileBorderSizeInWorldUnits; for (int i = 0; i < buckets.Length; i++) { buckets[i] = new NativeList(Allocator.Persistent); } var offset = -tileBuckets.Min; var clamp = new IntRect(0, 0, tileBuckets.Width - 1, tileBuckets.Height - 1); var meshes = meshCollection.meshes; for (int i = 0; i < meshes.Length; i++) { var mesh = meshes[i]; var bounds = mesh.bounds; var rect = tileLayout.GetTouchingTiles(bounds, borderExpansion); rect = IntRect.Intersection(rect.Offset(offset), clamp); for (int z = rect.ymin; z <= rect.ymax; z++) { for (int x = rect.xmin; x <= rect.xmax; x++) { buckets[x + z*tileBuckets.Width].Add(i); } } } // Concat buckets int allPointersCount = 0; for (int i = 0; i < buckets.Length; i++) allPointersCount += buckets[i].Length; var allPointers = new NativeArray(allPointersCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); var bucketRanges = new NativeArray(bucketCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); allPointersCount = 0; for (int i = 0; i < buckets.Length; i++) { // If we have an empty bucket at the end of the array then allPointersCount might be equal to allPointers.Length which would cause an assert to trigger. // So for empty buckets don't call the copy method if (buckets[i].Length > 0) { NativeArray.Copy(buckets[i].AsArray(), 0, allPointers, allPointersCount, buckets[i].Length); } allPointersCount += buckets[i].Length; bucketRanges[i] = allPointersCount; buckets[i].Dispose(); } return new BucketMapping { meshes = meshCollection.meshes, pointers = allPointers, bucketRanges = bucketRanges, }; } public Promise Schedule (DisposeArena arena) { var tileCount = tileRect.Area; Assert.IsTrue(tileCount > 0); var tileRectWidth = tileRect.Width; var tileRectDepth = tileRect.Height; // Find all meshes that could affect the graph var worldBounds = GetWorldSpaceBounds(TileBorderSizeInWorldUnits); if (dimensionMode == RecastGraph.DimensionMode.Dimension2D) { // In 2D mode, the bounding box of the graph only bounds it in the X and Y dimensions worldBounds.extents = new Vector3(worldBounds.extents.x, worldBounds.extents.y, float.PositiveInfinity); } var meshes = CollectMeshes(worldBounds); Profiler.BeginSample("PutMeshesIntoTileBuckets"); var buckets = PutMeshesIntoTileBuckets(meshes, tileRect); Profiler.EndSample(); Profiler.BeginSample("Allocating tiles"); var tileMeshes = new NativeArray(tileCount, Allocator.Persistent, NativeArrayOptions.ClearMemory); int width = tileLayout.tileSizeInVoxels.x + tileBorderSizeInVoxels*2; int depth = tileLayout.tileSizeInVoxels.y + tileBorderSizeInVoxels*2; var cellHeight = tileLayout.CellHeight; // TODO: Move inside BuildTileMeshBurst var voxelWalkableHeight = (uint)(walkableHeight/cellHeight); var voxelWalkableClimb = Mathf.RoundToInt(walkableClimb/cellHeight); var tileGraphSpaceBounds = new NativeArray(tileCount, Allocator.Persistent, NativeArrayOptions.UninitializedMemory); for (int z = 0; z < tileRectDepth; z++) { for (int x = 0; x < tileRectWidth; x++) { int tileIndex = x + z*tileRectWidth; var tileBounds = tileLayout.GetTileBoundsInGraphSpace(tileRect.xmin + x, tileRect.ymin + z); // Expand borderSize voxels on each side tileBounds.Expand(new Vector3(1, 0, 1)*TileBorderSizeInWorldUnits*2); tileGraphSpaceBounds[tileIndex] = tileBounds; } } Profiler.EndSample(); Profiler.BeginSample("Scheduling jobs"); var builders = new TileBuilderBurst[Mathf.Max(1, Mathf.Min(tileCount, Unity.Jobs.LowLevel.Unsafe.JobsUtility.JobWorkerCount + 1))]; var currentTileCounter = new NativeReference(0, Allocator.Persistent); JobHandle dependencies = default; var relevantGraphSurfaces = new NativeList(Allocator.Persistent); var c = RelevantGraphSurface.Root; while (c != null) { relevantGraphSurfaces.Add(new JobBuildRegions.RelevantGraphSurfaceInfo { position = c.transform.position, range = c.maxRange, }); c = c.Next; } // Having a few long running jobs is bad because Unity cannot inject more high priority jobs // in between tile calculations. So we run each builder a number of times. // Each step will just calculate one tile. int tilesPerJob = Mathf.CeilToInt(Mathf.Sqrt(tileCount)); // Number of tiles calculated if every builder runs once int tilesPerStep = tilesPerJob * builders.Length; // Round up to make sure we run the jobs enough times // We multiply by 2 to run a bit more jobs than strictly necessary. // This is to ensure that if one builder just gets a bunch of long running jobs // then the other builders can steal some work from it. int jobSteps = 2 * (tileCount + tilesPerStep - 1) / tilesPerStep; var jobTemplate = new JobBuildTileMeshFromVoxels { tileBuilder = builders[0], inputMeshes = buckets, tileGraphSpaceBounds = tileGraphSpaceBounds, voxelWalkableClimb = voxelWalkableClimb, voxelWalkableHeight = voxelWalkableHeight, voxelToTileSpace = Matrix4x4.Scale(new Vector3(tileLayout.cellSize, cellHeight, tileLayout.cellSize)) * Matrix4x4.Translate(-new Vector3(1, 0, 1)*TileBorderSizeInVoxels), cellSize = tileLayout.cellSize, cellHeight = cellHeight, maxSlope = Mathf.Max(maxSlope, 0.0001f), // Ensure maxSlope is not 0, as then horizontal surfaces can sometimes get excluded due to floating point errors dimensionMode = dimensionMode, backgroundTraversability = backgroundTraversability, graphToWorldSpace = tileLayout.transform.matrix, // Crop all tiles to ensure they are inside the graph bounds (even if the tiles did not line up perfectly with the bounding box). // Add the character radius, since it will be eroded away anyway, but subtract 1 voxel to ensure the nodes are strictly inside the bounding box graphSpaceLimits = new Vector2(tileLayout.graphSpaceSize.x + (characterRadiusInVoxels-1)*tileLayout.cellSize, tileLayout.graphSpaceSize.z + (characterRadiusInVoxels-1)*tileLayout.cellSize), characterRadiusInVoxels = characterRadiusInVoxels, tileBorderSizeInVoxels = tileBorderSizeInVoxels, minRegionSize = minRegionSize, maxEdgeLength = maxEdgeLength, contourMaxError = contourMaxError, maxTiles = tilesPerJob, relevantGraphSurfaces = relevantGraphSurfaces.AsArray(), relevantGraphSurfaceMode = this.relevantGraphSurfaceMode, }; jobTemplate.SetOutputMeshes(tileMeshes); jobTemplate.SetCounter(currentTileCounter); int maximumVoxelYCoord = (int)(tileLayout.graphSpaceSize.y / cellHeight); for (int i = 0; i < builders.Length; i++) { jobTemplate.tileBuilder = builders[i] = new TileBuilderBurst(width, depth, (int)voxelWalkableHeight, maximumVoxelYCoord); var dep = new JobHandle(); for (int j = 0; j < jobSteps; j++) { dep = jobTemplate.Schedule(dep); } dependencies = JobHandle.CombineDependencies(dependencies, dep); } JobHandle.ScheduleBatchedJobs(); Profiler.EndSample(); arena.Add(tileGraphSpaceBounds); arena.Add(relevantGraphSurfaces); arena.Add(buckets.bucketRanges); arena.Add(buckets.pointers); // Note: buckets.meshes references data in #meshes, so we don't have to dispose it separately arena.Add(meshes); // Dispose the mesh data after all jobs are completed. // Note that the jobs use pointers to this data which are not tracked by the safety system. for (int i = 0; i < builders.Length; i++) arena.Add(builders[i]); return new Promise(dependencies, new TileBuilderOutput { tileMeshes = new TileMeshesUnsafe(tileMeshes, tileRect, new Vector2(tileLayout.TileWorldSizeX, tileLayout.TileWorldSizeZ)), currentTileCounter = currentTileCounter, #if UNITY_EDITOR meshesUnreadableAtRuntime = meshes.meshesUnreadableAtRuntime, #endif }); } } }