using UnityEngine; using UnityEditor; using Pathfinding.Graphs.Navmesh; using UnityEditorInternal; namespace Pathfinding { /// Editor for the RecastGraph. [CustomGraphEditor(typeof(RecastGraph), "Recast Graph")] public class RecastGraphEditor : GraphEditor { public static bool tagMaskFoldout; public static bool meshesUnreadableAtRuntimeFoldout; ReorderableList tagMaskList; ReorderableList perLayerModificationsList; public enum UseTiles { UseTiles = 0, DontUseTiles = 1 } static readonly GUIContent[] DimensionModeLabels = new [] { new GUIContent("2D"), new GUIContent("3D"), }; static Rect SliceColumn (ref Rect rect, float width, float spacing = 0) { return GUIUtilityx.SliceColumn(ref rect, width, spacing); } static void DrawIndentedList (ReorderableList list) { GUILayout.BeginHorizontal(); GUILayout.Space(EditorGUI.IndentedRect(default).xMin); list.DoLayoutList(); GUILayout.Space(3); GUILayout.EndHorizontal(); } static void DrawColliderDetail (RecastGraph.CollectionSettings settings) { const float LowestApproximationError = 0.5f; settings.colliderRasterizeDetail = EditorGUILayout.Slider(new GUIContent("Round Collider Detail", "Controls the detail of the generated sphere and capsule meshes. "+ "Higher values may increase navmesh quality slightly, and lower values improve graph scanning performance."), Mathf.Round(10*settings.colliderRasterizeDetail)*0.1f, 0, 1.0f / LowestApproximationError); } void DrawCollectionSettings (RecastGraph.CollectionSettings settings, RecastGraph.DimensionMode dimensionMode) { settings.collectionMode = (RecastGraph.CollectionSettings.FilterMode)EditorGUILayout.EnumPopup("Filter objects by", settings.collectionMode); if (settings.collectionMode == RecastGraph.CollectionSettings.FilterMode.Layers) { settings.layerMask = EditorGUILayoutx.LayerMaskField("Layer Mask", settings.layerMask); } else { DrawIndentedList(tagMaskList); } if (dimensionMode == RecastGraph.DimensionMode.Dimension3D) { settings.rasterizeTerrain = EditorGUILayout.Toggle(new GUIContent("Rasterize Terrains", "Should a rasterized terrain be included"), settings.rasterizeTerrain); if (settings.rasterizeTerrain) { EditorGUI.indentLevel++; settings.rasterizeTrees = EditorGUILayout.Toggle(new GUIContent("Rasterize Trees", "Rasterize tree colliders on terrains. " + "If the tree prefab has a collider, that collider will be rasterized. " + "Otherwise a simple box collider will be used and the script will " + "try to adjust it to the tree's scale, it might not do a very good job though so " + "an attached collider is preferable."), settings.rasterizeTrees); settings.terrainHeightmapDownsamplingFactor = EditorGUILayout.IntField(new GUIContent("Heightmap Downsampling", "How much to downsample the terrain's heightmap. A lower value is better, but slower to scan"), settings.terrainHeightmapDownsamplingFactor); settings.terrainHeightmapDownsamplingFactor = Mathf.Max(1, settings.terrainHeightmapDownsamplingFactor); EditorGUI.indentLevel--; } settings.rasterizeMeshes = EditorGUILayout.Toggle(new GUIContent("Rasterize Meshes", "Should meshes be rasterized and used for building the navmesh"), settings.rasterizeMeshes); settings.rasterizeColliders = EditorGUILayout.Toggle(new GUIContent("Rasterize Colliders", "Should colliders be rasterized and used for building the navmesh"), settings.rasterizeColliders); } else { // Colliders are always rasterized in 2D mode EditorGUI.BeginDisabledGroup(true); EditorGUILayout.Toggle(new GUIContent("Rasterize Colliders", "Should colliders be rasterized and used for building the navmesh. In 2D mode, this is always enabled."), true); EditorGUI.EndDisabledGroup(); } if (settings.rasterizeMeshes && settings.rasterizeColliders && dimensionMode == RecastGraph.DimensionMode.Dimension3D) { EditorGUILayout.HelpBox("You are rasterizing both meshes and colliders. This is likely just duplicate work if the colliders and meshes are similar in shape. You can use the RecastMeshObj component" + " to always include some specific objects regardless of what the above settings are set to.", MessageType.Info); } } public override void OnEnable () { base.OnEnable(); var graph = target as RecastGraph; tagMaskList = new ReorderableList(graph.collectionSettings.tagMask, typeof(string), true, true, true, true) { drawElementCallback = (Rect rect, int index, bool active, bool isFocused) => { graph.collectionSettings.tagMask[index] = EditorGUI.TagField(rect, graph.collectionSettings.tagMask[index]); }, drawHeaderCallback = (Rect rect) => { GUI.Label(rect, "Tag mask"); }, elementHeight = EditorGUIUtility.singleLineHeight, onAddCallback = (ReorderableList list) => { graph.collectionSettings.tagMask.Add("Untagged"); } }; perLayerModificationsList = new ReorderableList(graph.perLayerModifications, typeof(RecastGraph.PerLayerModification), true, true, true, true) { drawElementCallback = (Rect rect, int index, bool active, bool isFocused) => { var element = graph.perLayerModifications[index]; var w = rect.width; var spacing = EditorGUIUtility.standardVerticalSpacing; element.layer = EditorGUI.LayerField(SliceColumn(ref rect, w * 0.3f, spacing), element.layer); if (element.mode == RecastMeshObj.Mode.WalkableSurfaceWithTag) { element.mode = (RecastMeshObj.Mode)EditorGUI.EnumPopup(SliceColumn(ref rect, w * 0.4f, spacing), element.mode); element.surfaceID = Util.EditorGUILayoutHelper.TagField(rect, GUIContent.none, element.surfaceID, AstarPathEditor.EditTags); element.surfaceID = Mathf.Clamp(element.surfaceID, 0, GraphNode.MaxTagIndex); } else if (element.mode == RecastMeshObj.Mode.WalkableSurfaceWithSeam) { element.mode = (RecastMeshObj.Mode)EditorGUI.EnumPopup(SliceColumn(ref rect, w * 0.4f, spacing), element.mode); string helpTooltip = "All surfaces on this mesh will be walkable and a " + "seam will be created between the surfaces on this mesh and the surfaces on other meshes (with a different surface id)"; GUI.Label(SliceColumn(ref rect, 70, spacing), new GUIContent("Surface ID", helpTooltip)); element.surfaceID = Mathf.Max(0, EditorGUI.IntField(rect, new GUIContent("", helpTooltip), element.surfaceID)); } else { element.mode = (RecastMeshObj.Mode)EditorGUI.EnumPopup(rect, element.mode); } graph.perLayerModifications[index] = element; }, drawHeaderCallback = (Rect rect) => { GUI.Label(rect, "Per Layer Modifications"); }, elementHeight = EditorGUIUtility.singleLineHeight, onAddCallback = (ReorderableList list) => { // Find the first layer that is not already modified var availableLayers = graph.collectionSettings.layerMask; foreach (var mod in graph.perLayerModifications) { availableLayers &= ~(1 << mod.layer); } var newMod = RecastGraph.PerLayerModification.Default; for (int i = 0; i < 32; i++) { if ((availableLayers & (1 << i)) != 0) { newMod.layer = i; break; } } graph.perLayerModifications.Add(newMod); } }; } public override void OnInspectorGUI (NavGraph target) { var graph = target as RecastGraph; Header("Shape"); graph.dimensionMode = (RecastGraph.DimensionMode)EditorGUILayout.Popup(new GUIContent("Dimensions", "Should the graph be for a 2D or 3D world?"), (int)graph.dimensionMode, DimensionModeLabels); if (graph.dimensionMode == RecastGraph.DimensionMode.Dimension2D && Mathf.Abs(Vector3.Dot(Quaternion.Euler(graph.rotation) * Vector3.up, Vector3.forward)) < 0.99999f) { EditorGUI.indentLevel++; EditorGUILayout.BeginHorizontal(EditorStyles.helpBox); GUILayout.Label(EditorGUIUtility.IconContent("console.warnicon"), GUILayout.ExpandWidth(false)); GUILayout.BeginVertical(); GUILayout.FlexibleSpace(); GUILayout.BeginHorizontal(); GUILayout.Label("Your graph is not in the XY plane"); if (GUILayout.Button("Align")) { graph.rotation = new Vector3(-90, 0, 0); graph.forcedBoundsCenter = new Vector3(graph.forcedBoundsCenter.x, graph.forcedBoundsCenter.y, -graph.forcedBoundsSize.y * 0.5f); } GUILayout.EndHorizontal(); GUILayout.FlexibleSpace(); GUILayout.EndVertical(); EditorGUILayout.EndHorizontal(); EditorGUI.indentLevel--; } // In 3D mode, we use the graph's center as the pivot point, but in 2D mode, we use the center of the base plane of the graph as the pivot point. // This makes sense because in 2D mode, you typically want to set the base plane's center to Z=0, and you don't care much about the height of the graph. var pivot = graph.dimensionMode == RecastGraph.DimensionMode.Dimension2D ? new Vector3(0.0f, -0.5f, 0.0f) : Vector3.zero; var centerOffset = Quaternion.Euler(graph.rotation) * Vector3.Scale(graph.forcedBoundsSize, pivot); var newCenter = EditorGUILayout.Vector3Field("Center", graph.forcedBoundsCenter + centerOffset); var newSize = EditorGUILayout.Vector3Field("Size", graph.forcedBoundsSize); // Make sure the bounding box is not infinitely thin along any axis newSize = Vector3.Max(newSize, Vector3.one * 0.001f); // Recalculate the center offset with the new size, and then adjust the center so that the pivot point stays the same if the size changes centerOffset = Quaternion.Euler(graph.rotation) * Vector3.Scale(newSize, pivot); graph.forcedBoundsCenter = RoundVector3(newCenter) - centerOffset; graph.forcedBoundsSize = RoundVector3(newSize); graph.rotation = RoundVector3(EditorGUILayout.Vector3Field("Rotation", graph.rotation)); long estWidth = Mathf.RoundToInt(Mathf.Ceil(graph.forcedBoundsSize.x / graph.cellSize)); long estDepth = Mathf.RoundToInt(Mathf.Ceil(graph.forcedBoundsSize.z / graph.cellSize)); EditorGUI.BeginDisabledGroup(true); var estTilesX = (estWidth + graph.editorTileSize - 1) / graph.editorTileSize; var estTilesZ = (estDepth + graph.editorTileSize - 1) / graph.editorTileSize; var label = estWidth.ToString() + " x " + estDepth.ToString(); if (graph.useTiles) { label += " voxels, divided into " + (estTilesX*estTilesZ) + " tiles"; } EditorGUILayout.LabelField(new GUIContent("Size", "Based on the voxel size and the bounding box"), new GUIContent(label)); EditorGUI.EndDisabledGroup(); // Show a warning if the number of voxels is too large if (estWidth*estDepth >= 3000*3000) { GUIStyle helpBox = GUI.skin.FindStyle("HelpBox") ?? GUI.skin.FindStyle("Box"); Color preColor = GUI.color; if (estWidth*estDepth >= 8192*8192) { GUI.color = Color.red; } else { GUI.color = Color.yellow; } GUILayout.Label("Warning: Might take some time to calculate", helpBox); GUI.color = preColor; } if (!editor.isPrefab) { if (GUILayout.Button(new GUIContent("Snap bounds to scene", "Will snap the bounds of the graph to exactly contain all meshes in the scene that matches the masks."))) { graph.SnapForceBoundsToScene(); GUI.changed = true; } } Separator(); Header("Input Filtering"); DrawCollectionSettings(graph.collectionSettings, graph.dimensionMode); Separator(); Header("Rasterization"); graph.cellSize = EditorGUILayout.FloatField(new GUIContent("Voxel Size", "Size of one voxel in world units"), graph.cellSize); if (graph.cellSize < 0.001F) graph.cellSize = 0.001F; graph.useTiles = (UseTiles)EditorGUILayout.EnumPopup("Use Tiles", graph.useTiles ? UseTiles.UseTiles : UseTiles.DontUseTiles) == UseTiles.UseTiles; if (graph.useTiles) { EditorGUI.indentLevel++; graph.editorTileSize = EditorGUILayout.IntField(new GUIContent("Tile Size (voxels)", "Size in voxels of a single tile.\n" + "This is the width of the tile.\n" + "\n" + "A large tile size can be faster to initially scan (but beware of out of memory issues if you try with a too large tile size in a large world)\n" + "smaller tile sizes are (much) faster to update.\n" + "\n" + "Different tile sizes can affect the quality of paths. It is often good to split up huge open areas into several tiles for\n" + "better quality paths, but too small tiles can lead to effects looking like invisible obstacles.\n\n" + "Typical values are between 64 and 256"), graph.editorTileSize); graph.editorTileSize = Mathf.Max(10, graph.editorTileSize); EditorGUI.indentLevel--; } if (graph.dimensionMode == RecastGraph.DimensionMode.Dimension3D) { graph.walkableHeight = EditorGUILayout.DelayedFloatField(new GUIContent("Character Height", "Minimum distance to the roof for an area to be walkable"), graph.walkableHeight); graph.walkableHeight = Mathf.Max(graph.walkableHeight, 0); graph.characterRadius = EditorGUILayout.FloatField(new GUIContent("Character Radius", "Radius of the character. It's good to add some margin.\nIn world units."), graph.characterRadius); graph.characterRadius = Mathf.Max(graph.characterRadius, 0); if (graph.characterRadius < graph.cellSize * 2) { EditorGUILayout.HelpBox("For best navmesh quality, it is recommended to keep the character radius at least 2 times as large as the voxel size. Smaller voxels will give you higher quality navmeshes, but it will take more time to scan the graph.", MessageType.Warning); } graph.walkableClimb = EditorGUILayout.FloatField(new GUIContent("Max Step Height", "How high can the character step"), graph.walkableClimb); // A walkableClimb higher than this 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 a navmesh without links) if (graph.walkableClimb >= graph.walkableHeight) { graph.walkableClimb = graph.walkableHeight; EditorGUILayout.HelpBox("Max Step Height should be less than Character Height. Clamping to " + graph.walkableHeight+".", MessageType.Warning); } else if (graph.walkableClimb < 0) { graph.walkableClimb = 0; } } if (graph.dimensionMode == RecastGraph.DimensionMode.Dimension3D) { graph.maxSlope = EditorGUILayout.Slider(new GUIContent("Max Slope", "Approximate maximum slope"), graph.maxSlope, 0F, 90F); } graph.maxEdgeLength = EditorGUILayout.FloatField(new GUIContent("Max Border Edge Length", "Maximum length of one border edge in the completed navmesh before it is split. A lower value can often yield better quality graphs, but don't use so low values so that you get a lot of thin triangles."), graph.maxEdgeLength); graph.maxEdgeLength = graph.maxEdgeLength < graph.cellSize ? graph.cellSize : graph.maxEdgeLength; // This is actually a float, but to make things easier for the user, we only allow picking integers. Small changes don't matter that much anyway. graph.contourMaxError = EditorGUILayout.IntSlider(new GUIContent("Edge Simplification", "Simplifies the edges of the navmesh such that it is no more than this number of voxels away from the true value.\nIn voxels."), Mathf.RoundToInt(graph.contourMaxError), 0, 5); graph.minRegionSize = EditorGUILayout.FloatField(new GUIContent("Min Region Size", "Small regions will be removed. In voxels"), graph.minRegionSize); if (graph.dimensionMode == RecastGraph.DimensionMode.Dimension2D) { graph.backgroundTraversability = (RecastGraph.BackgroundTraversability)EditorGUILayout.EnumPopup("Background traversability", graph.backgroundTraversability); } if (graph.collectionSettings.rasterizeColliders || (graph.dimensionMode == RecastGraph.DimensionMode.Dimension3D && graph.collectionSettings.rasterizeTerrain && graph.collectionSettings.rasterizeTrees)) { DrawColliderDetail(graph.collectionSettings); } DrawIndentedList(perLayerModificationsList); int seenLayers = 0; for (int i = 0; i < graph.perLayerModifications.Count; i++) { if ((seenLayers & 1 << graph.perLayerModifications[i].layer) != 0) { EditorGUILayout.HelpBox("Duplicate layers. Each layer can only be modified by a single rule.", MessageType.Error); break; } seenLayers |= 1 << graph.perLayerModifications[i].layer; } var countStillUnreadable = 0; for (int i = 0; graph.meshesUnreadableAtRuntime != null && i < graph.meshesUnreadableAtRuntime.Count; i++) { countStillUnreadable += graph.meshesUnreadableAtRuntime[i].Item2.isReadable ? 0 : 1; } if (countStillUnreadable > 0) { GUILayout.BeginHorizontal(); GUILayout.Space(EditorGUI.IndentedRect(new Rect(0, 0, 0, 0)).xMin); EditorGUILayout.BeginVertical(EditorStyles.helpBox); GUILayout.BeginHorizontal(); GUILayout.BeginVertical(); GUILayout.FlexibleSpace(); meshesUnreadableAtRuntimeFoldout = GUILayout.Toggle(meshesUnreadableAtRuntimeFoldout, "", EditorStyles.foldout, GUILayout.Width(10)); GUILayout.FlexibleSpace(); GUILayout.EndVertical(); GUILayout.Label(EditorGUIUtility.IconContent("console.warnicon"), GUILayout.ExpandWidth(false)); GUILayout.Label(graph.meshesUnreadableAtRuntime.Count + " " + (graph.meshesUnreadableAtRuntime.Count > 1 ? "meshes" : "mesh") + " will be ignored if scanned in a standalone build, because they are marked as not readable." + "If you plan to scan the graph in a standalone build, all included meshes must be marked as read/write in their import settings.", EditorStyles.wordWrappedMiniLabel); // EditorGUI.DrawTextureTransparent() EditorGUIUtility.IconContent("console.warnicon") GUILayout.EndHorizontal(); if (meshesUnreadableAtRuntimeFoldout) { EditorGUILayout.Separator(); for (int i = 0; i < graph.meshesUnreadableAtRuntime.Count; i++) { var(source, mesh) = graph.meshesUnreadableAtRuntime[i]; if (!mesh.isReadable) { GUILayout.BeginHorizontal(); EditorGUI.BeginDisabledGroup(true); EditorGUILayout.ObjectField(source, typeof(Mesh), true); EditorGUILayout.ObjectField(mesh, typeof(Mesh), false); EditorGUI.EndDisabledGroup(); if (GUILayout.Button("Make readable")) { var importer = ModelImporter.GetAtPath(AssetDatabase.GetAssetPath(mesh)) as ModelImporter; if (importer != null) { importer.isReadable = true; importer.SaveAndReimport(); } } GUILayout.EndHorizontal(); } } } EditorGUILayout.EndVertical(); GUILayout.EndHorizontal(); } Separator(); Header("Runtime Settings"); graph.enableNavmeshCutting = EditorGUILayout.Toggle(new GUIContent("Affected by Navmesh Cuts", "Makes this graph affected by NavmeshCut and NavmeshAdd components. See the documentation for more info."), graph.enableNavmeshCutting); Separator(); Header("Debug"); GUILayout.BeginHorizontal(); GUILayout.Space(18); graph.showMeshSurface = GUILayout.Toggle(graph.showMeshSurface, new GUIContent("Show surface", "Toggles gizmos for drawing the surface of the mesh"), EditorStyles.miniButtonLeft); graph.showMeshOutline = GUILayout.Toggle(graph.showMeshOutline, new GUIContent("Show outline", "Toggles gizmos for drawing an outline of the nodes"), EditorStyles.miniButtonMid); graph.showNodeConnections = GUILayout.Toggle(graph.showNodeConnections, new GUIContent("Show connections", "Toggles gizmos for drawing node connections"), EditorStyles.miniButtonRight); GUILayout.EndHorizontal(); Separator(); Header("Advanced"); graph.relevantGraphSurfaceMode = (RecastGraph.RelevantGraphSurfaceMode)EditorGUILayout.EnumPopup(new GUIContent("Relevant Graph Surface Mode", "Require every region to have a RelevantGraphSurface component inside it.\n" + "A RelevantGraphSurface component placed in the scene specifies that\n" + "the navmesh region it is inside should be included in the navmesh.\n\n" + "If this is set to OnlyForCompletelyInsideTile\n" + "a navmesh region is included in the navmesh if it\n" + "has a RelevantGraphSurface inside it, or if it\n" + "is adjacent to a tile border. This can leave some small regions\n" + "which you didn't want to have included because they are adjacent\n" + "to tile borders, but it removes the need to place a component\n" + "in every single tile, which can be tedious (see below).\n\n" + "If this is set to RequireForAll\n" + "a navmesh region is included only if it has a RelevantGraphSurface\n" + "inside it. Note that even though the navmesh\n" + "looks continous between tiles, the tiles are computed individually\n" + "and therefore you need a RelevantGraphSurface component for each\n" + "region and for each tile."), graph.relevantGraphSurfaceMode); #pragma warning disable 618 if (graph.nearestSearchOnlyXZ) { graph.nearestSearchOnlyXZ = EditorGUILayout.Toggle(new GUIContent("Nearest node queries in XZ space", "Recomended for single-layered environments.\nFaster but can be inacurate esp. in multilayered contexts."), graph.nearestSearchOnlyXZ); EditorGUILayout.HelpBox("The global toggle for node queries in XZ space has been deprecated. Use the NNConstraint settings instead.", MessageType.Warning); } #pragma warning restore 618 if (GUILayout.Button("Export to .obj file")) { editor.RunTask(() => ExportToFile(graph)); } } static readonly Vector3[] handlePoints = new [] { new Vector3(-1, 0, 0), new Vector3(1, 0, 0), new Vector3(0, 0, -1), new Vector3(0, 0, 1), new Vector3(0, 1, 0), new Vector3(0, -1, 0) }; public override void OnSceneGUI (NavGraph target) { var graph = target as RecastGraph; Handles.matrix = Matrix4x4.identity; Handles.color = AstarColor.BoundsHandles; Handles.CapFunction cap = Handles.CylinderHandleCap; var center = graph.forcedBoundsCenter; Matrix4x4 matrix = Matrix4x4.TRS(center, Quaternion.Euler(graph.rotation), graph.forcedBoundsSize * 0.5f); if (Tools.current == Tool.Scale) { const float HandleScale = 0.1f; Vector3 mn = Vector3.zero; Vector3 mx = Vector3.zero; EditorGUI.BeginChangeCheck(); for (int i = 0; i < handlePoints.Length; i++) { var ps = matrix.MultiplyPoint3x4(handlePoints[i]); Vector3 p = matrix.inverse.MultiplyPoint3x4(Handles.Slider(ps, ps - center, HandleScale*HandleUtility.GetHandleSize(ps), cap, 0)); if (i == 0) { mn = mx = p; } else { mn = Vector3.Min(mn, p); mx = Vector3.Max(mx, p); } } if (EditorGUI.EndChangeCheck()) { graph.forcedBoundsCenter = matrix.MultiplyPoint3x4((mn + mx) * 0.5f); graph.forcedBoundsSize = Vector3.Scale(graph.forcedBoundsSize, (mx - mn) * 0.5f); } } else if (Tools.current == Tool.Move) { EditorGUI.BeginChangeCheck(); center = Handles.PositionHandle(center, Tools.pivotRotation == PivotRotation.Global ? Quaternion.identity : Quaternion.Euler(graph.rotation)); if (EditorGUI.EndChangeCheck() && Tools.viewTool != ViewTool.Orbit) { graph.forcedBoundsCenter = center; } } else if (Tools.current == Tool.Rotate) { EditorGUI.BeginChangeCheck(); var rot = Handles.RotationHandle(Quaternion.Euler(graph.rotation), graph.forcedBoundsCenter); if (EditorGUI.EndChangeCheck() && Tools.viewTool != ViewTool.Orbit) { graph.rotation = rot.eulerAngles; } } } /// Exports the INavmesh graph to a .obj file public static void ExportToFile (NavmeshBase target) { if (target == null) return; NavmeshTile[] tiles = target.GetTiles(); if (tiles == null) { if (EditorUtility.DisplayDialog("Scan graph before exporting?", "The graph does not contain any mesh data. Do you want to scan it?", "Ok", "Cancel")) { AstarPathEditor.MenuScan(); tiles = target.GetTiles(); if (tiles == null) return; } else { return; } } string path = EditorUtility.SaveFilePanel("Export .obj", "", "navmesh.obj", "obj"); if (path == "") return; //Generate .obj var sb = new System.Text.StringBuilder(); string name = System.IO.Path.GetFileNameWithoutExtension(path); sb.Append("g ").Append(name).AppendLine(); //Vertices start from 1 int vCount = 1; //Define single texture coordinate to zero sb.Append("vt 0 0\n"); for (int t = 0; t < tiles.Length; t++) { NavmeshTile tile = tiles[t]; if (tile == null) continue; var vertices = tile.verts; //Write vertices for (int i = 0; i < vertices.Length; i++) { var v = (Vector3)vertices[i]; sb.Append(string.Format("v {0} {1} {2}\n", -v.x, v.y, v.z)); } //Write triangles TriangleMeshNode[] nodes = tile.nodes; for (int i = 0; i < nodes.Length; i++) { TriangleMeshNode node = nodes[i]; if (node == null) { Debug.LogError("Node was null or no TriangleMeshNode. Critical error. Graph type " + target.GetType().Name); return; } if (node.GetVertexArrayIndex(0) < 0 || node.GetVertexArrayIndex(0) >= vertices.Length) throw new System.Exception("ERR"); sb.Append(string.Format("f {0}/1 {1}/1 {2}/1\n", (node.GetVertexArrayIndex(0) + vCount), (node.GetVertexArrayIndex(1) + vCount), (node.GetVertexArrayIndex(2) + vCount))); } vCount += vertices.Length; } string obj = sb.ToString(); using (var sw = new System.IO.StreamWriter(path)) { sw.Write(obj); } } } }