using System; using System.IO; using System.Linq; using NUnit.Framework; using UnityEngine; using UnityEngine.TerrainTools; using Object = UnityEngine.Object; namespace UnityEditor.TerrainTools { class RegressionTests { GameObject m_TerrainGO; Terrain m_TerrainComponent; private int m_NumTerrains; [Test] [TestCase(0f, 1f, Heightmap.Format.PNG, Ignore = "Failing on Ubuntu")] [TestCase(.25f, .75f, Heightmap.Format.PNG, Ignore = "Failing on Ubuntu")] [TestCase(0f, 1f, Heightmap.Format.TGA, Ignore = "Failing on Ubuntu")] [TestCase(.25f, .75f, Heightmap.Format.TGA, Ignore = "Failing on Ubuntu")] [TestCase(0f, 1f, Heightmap.Format.RAW)] [TestCase(.25f, .75f, Heightmap.Format.RAW)] [TestCase(0f, 1f, Heightmap.Format.RAW, Heightmap.Depth.Bit8)] [TestCase(.25f, .75f, Heightmap.Format.RAW, Heightmap.Depth.Bit8)] public void TerrainToolboxUtilites_WhenExportHeightmap_LevelCorrectionWorks(float min, float max, Heightmap.Format format, Heightmap.Depth depth = Heightmap.Depth.Bit16) { TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; Texture2D gradientTexture = CreateGradientTexture(); int heightmapResolution = 513; int numberOfTiles = 1; int baseLevel = 0; int remapLevel = 1; ToolboxHelper.CopyTextureToTerrainHeight(m_TerrainComponent.terrainData, gradientTexture, Vector2Int.zero, heightmapResolution, numberOfTiles, baseLevel, remapLevel); Selection.activeGameObject = m_TerrainGO; m_TerrainGO.name = "TestTerrain"; m_TerrainComponent.name = "TestComponent"; RenderTexture oldRT = RenderTexture.active; RenderTexture.active = m_TerrainComponent.terrainData.heightmapTexture; //Run Tests and Cleanup files string fileName = m_TerrainGO.name + "_heightmap"; string path = Path.Combine(toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightmapFolderPath, fileName); switch (format) { case Heightmap.Format.PNG: path += ".png"; Assert.IsTrue(TestLevelCorrection(toolboxWindow, new Vector2(min, max), path, format)); FileUtil.DeleteFileOrDirectory(path); FileUtil.DeleteFileOrDirectory(path + ".meta"); break; case Heightmap.Format.TGA: path += ".tga"; Assert.IsTrue(TestLevelCorrection(toolboxWindow, new Vector2(min, max), path, format)); FileUtil.DeleteFileOrDirectory(path); FileUtil.DeleteFileOrDirectory(path + ".meta"); break; case Heightmap.Format.RAW: path += ".raw"; Assert.IsTrue(TestLevelCorrection(toolboxWindow, new Vector2(min, max), path, depth)); FileUtil.DeleteFileOrDirectory(path); FileUtil.DeleteFileOrDirectory(path + ".meta"); break; } AssetDatabase.Refresh(); RenderTexture.active = oldRT; toolboxWindow.Close(); } /// /// This overloaded method deals specifically with testing the level correction of the raw format /// /// Window where the Export Heightmap Utilities live /// Min and Max values of the remap /// Heightmap File Format /// String path of the files directory location /// Heightmap Bit Depth /// bool TestLevelCorrection(TerrainToolboxWindow toolboxWindow, Vector2 minMaxRemap, string path, Heightmap.Depth depth) { //Execute the repro steps in code toolboxWindow.m_TerrainUtilitiesMode.m_Settings.ExportHeightRemapMin = minMaxRemap.x; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.ExportHeightRemapMax = minMaxRemap.y; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightFormat = Heightmap.Format.RAW; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightmapDepth = depth; toolboxWindow.m_TerrainUtilitiesMode.m_SelectedDepth = (depth == Heightmap.Depth.Bit16) ? 0 : 1; toolboxWindow.m_TerrainUtilitiesMode.ExportHeightmaps(new Object[] { m_TerrainComponent }); //Get byte data of the terrain's heightmap TerrainData terrainData = m_TerrainComponent.terrainData; #if UNITY_2019_3_OR_NEWER int heightmapWidth = terrainData.heightmapResolution - 1; int heightmapHeight = terrainData.heightmapResolution - 1; #else int heightmapWidth = terrainData.heightmapWidth - 1; int heightmapHeight = terrainData.heightmapHeight - 1; #endif float[,] heights = terrainData.GetHeights(0, 0, heightmapWidth, heightmapHeight); byte[] data = new byte[heightmapWidth * heightmapHeight * (int)depth]; if (depth == Heightmap.Depth.Bit16) { float normalize = (1 << 16); for (int y = 0; y < heightmapHeight; ++y) { for (int x = 0; x < heightmapWidth; ++x) { //Remapping the heightmap data int index = x + y * heightmapWidth; float remappedHeight = heights[y, x] * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; int height = Mathf.RoundToInt(remappedHeight * normalize); ushort compressedHeight = (ushort)Mathf.Clamp(height, 0, ushort.MaxValue); byte[] byteData = System.BitConverter.GetBytes(compressedHeight); if ((toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightmapByteOrder == ToolboxHelper.ByteOrder.Mac) == System.BitConverter.IsLittleEndian) { data[index * 2 + 0] = byteData[1]; data[index * 2 + 1] = byteData[0]; } else { data[index * 2 + 0] = byteData[0]; data[index * 2 + 1] = byteData[1]; } } } } else { float normalize = (1 << 8); for (int y = 0; y < heightmapHeight; ++y) { for (int x = 0; x < heightmapWidth; ++x) { //Remapping the heightmap data int index = x + y * heightmapWidth; float remappedHeight = heights[y, x] * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; int height = Mathf.RoundToInt(remappedHeight * normalize); byte compressedHeight = (byte)Mathf.Clamp(height, 0, byte.MaxValue); data[index] = compressedHeight; } } } //Compare both the original and regression test data byte[] rawByteData = File.ReadAllBytes(path); return data.SequenceEqual(rawByteData); } /// /// This overloaded method deals specifically with testing the level correction of the png and tga format /// /// Window where the Export Heightmap Utilities live /// Min and Max values of the remap /// String path of the files directory location /// Heightmap File Format /// bool TestLevelCorrection(TerrainToolboxWindow toolboxWindow, Vector2 minMaxRemap, string path, Heightmap.Format format) { //Execute the repro steps in code toolboxWindow.m_TerrainUtilitiesMode.m_Settings.ExportHeightRemapMin = minMaxRemap.x; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.ExportHeightRemapMax = minMaxRemap.y; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightFormat = format; toolboxWindow.m_TerrainUtilitiesMode.ExportHeightmaps(new Object[] { m_TerrainComponent }); //Get heightmap data to compare int width = RenderTexture.active.width - 1; int height = RenderTexture.active.height - 1; var texture = new Texture2D(width, height, RenderTexture.active.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.None); texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); //Remap Texture Color[] pixels = texture.GetPixels(); for (int i = 0; i < pixels.Length; i += 4) { pixels[i].r = (pixels[i].r * 2) * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; pixels[i + 1].r = (pixels[i + 1].r * 2) * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; pixels[i + 2].r = (pixels[i + 2].r * 2) * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; pixels[i + 3].r = (pixels[i + 3].r * 2) * (minMaxRemap.y - minMaxRemap.x) + minMaxRemap.x; } texture.SetPixels(pixels); texture.Apply(); //Compare both the original and regression test data byte[] byteData = File.ReadAllBytes(path); return format == Heightmap.Format.PNG ? texture.EncodeToPNG().SequenceEqual(byteData) : texture.EncodeToTGA().SequenceEqual(byteData); } [Test] public void TerrainToolboxUtilites_WhenSelectSplatmap_DoesNotIndexOutOfRange() { //Collect data, create needed objects Texture2D texture = new Texture2D(512, 512); TerrainLayer layer = new TerrainLayer(); layer.diffuseTexture = texture; TerrainLayer[] terrainLayers = { layer }; //Create gameobject with terrain component m_TerrainGO = new GameObject(); Terrain terrain = m_TerrainGO.AddComponent(); terrain.terrainData = new TerrainData(); //Add splatmap to terrain in order to import into the Utilities Window terrain.terrainData.terrainLayers = terrainLayers; //Execute the repro steps in code TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; Selection.activeGameObject = m_TerrainGO; toolboxWindow.m_TerrainUtilitiesMode.ImportSplatmapsFromTerrain(); Assert.That(() => { toolboxWindow.m_TerrainUtilitiesMode.ExportSplatmapsToTerrain(true); }, !Throws.TypeOf()); toolboxWindow.Close(); } [Test] public void TerrainToolboxUtilites_WhenApplySplatmaps_DoesNotDividebyZero() { // Preparation: // Collect data, create needed objects Texture2D texture = new Texture2D(512, 512); TerrainLayer layer = new TerrainLayer(); layer.diffuseTexture = texture; TerrainLayer[] terrainLayers = {layer}; //Add splatmap to terrain in order to import into the Utilities Window m_TerrainComponent.terrainData.terrainLayers = terrainLayers; Selection.activeGameObject = m_TerrainGO; // Execute the repro steps in code TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; toolboxWindow.m_TerrainUtilitiesMode.ImportSplatmapsFromTerrain(true); Selection.activeGameObject = null; Assert.That(() => { toolboxWindow.m_TerrainUtilitiesMode.ExportSplatmapsToTerrain(true); }, !Throws.TypeOf()); toolboxWindow.Close(); } [Test] [TestCase(2, 33)] [TestCase(2, 65)] [TestCase(4, 65)] [TestCase(2, 129)] [TestCase(8, 129)] [TestCase(2, 513)] [TestCase(0, 65)] [TestCase(-2, 65)] [TestCase(-1, 65)] public void TerrainToolboxUtilities_WhenSplitTerrain_HeightmapResolutionIsCorrect(int split, int originalHeightmapRes) { UnityEditor.Undo.IncrementCurrentGroup(); TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; Texture2D gradientTexture = CreateGradientTexture(); int baseLevel = 0; int remapLevel = 1; int numberOfTiles = 1; ToolboxHelper.CopyTextureToTerrainHeight(m_TerrainComponent.terrainData, gradientTexture, Vector2Int.zero, originalHeightmapRes, numberOfTiles, baseLevel, remapLevel); Selection.activeGameObject = m_TerrainGO; m_TerrainGO.name = "TestTerrain"; m_TerrainComponent.name = "TestComponent"; RenderTexture oldRT = RenderTexture.active; RenderTexture.active = m_TerrainComponent.terrainData.heightmapTexture; // Run the test TestSplitTerrainHeightmapResolution(toolboxWindow, originalHeightmapRes, split); AssetDatabase.Refresh(); RenderTexture.active = oldRT; // Undo the split UnityEditor.Undo.PerformUndo(); // Check that the original terrain heightmap resolution is unchanged Assert.AreEqual(originalHeightmapRes, m_TerrainComponent.terrainData.heightmapResolution); toolboxWindow.Close(); } void TestSplitTerrainHeightmapResolution(TerrainToolboxWindow toolboxWindow, int heightmapRes, int split) { // Set up parent object so we can locate the split tiles for cleanup after testing int groupingId = 12345; var parent = new GameObject().AddComponent(); parent.GroupID = groupingId; m_TerrainComponent.transform.SetParent(parent.transform); int actualSplit = split <= 1 ? 2 : split; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.HeightmapResolution = heightmapRes; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.TileSplit = actualSplit; toolboxWindow.m_TerrainUtilitiesMode.SplitTerrain(m_TerrainComponent, groupingId, true); // The children should include the original terrain gameobject + the newly created tiles int childCount = parent.transform.childCount; Assert.AreEqual(actualSplit * actualSplit + 1, childCount); // Test and clean up the split tiles (skip the first child as it is the original terrain object) for (int i = 1; i < childCount; i++) { var child = parent.transform.GetChild(i).GetComponent(); Assert.AreEqual(child.terrainData.heightmapResolution - 1, GetExpectedTileHeightmapResolution(heightmapRes, actualSplit)); string path = Path.Combine("Assets/Terrain", child.transform.name + ".asset"); FileUtil.DeleteFileOrDirectory(path); FileUtil.DeleteFileOrDirectory(path + ".meta"); } } int GetExpectedTileHeightmapResolution(int heightmapRes, int split) { int minHeightmapRes = 32; int newHeightmapRes = (heightmapRes - 1) / split; return Math.Max(newHeightmapRes, minHeightmapRes); } [Test] [TestCase(1, 1, 2)] [TestCase(3, 3, 4)] [TestCase(5, 5, 2)] public void TerrainToolboxUtilites_WhenSplitTerrain_MissingTrees(int amountOfTreesX, int amountOfTreesZ, int tileSplit) { //Setup tree prefab (Needs to be persistent) GameObject treePrefab = GameObject.CreatePrimitive(PrimitiveType.Cube); treePrefab.GetComponent().sharedMaterial.shader = Shader.Find("Nature/Tree Soft Occlusion Bark"); string localPath = $"Assets/{treePrefab.name}.prefab"; localPath = AssetDatabase.GenerateUniqueAssetPath(localPath); PrefabUtility.SaveAsPrefabAsset(treePrefab, localPath); treePrefab = AssetDatabase.LoadAssetAtPath(localPath, typeof(GameObject)) as GameObject; //Setup terrain object with trees TerrainData terrainData = m_TerrainComponent.terrainData; TreePrototype prototype = new TreePrototype(); prototype.prefab = treePrefab; terrainData.treePrototypes = new TreePrototype[] { prototype }; TreeInstance[] treeInstancesArray = new TreeInstance[amountOfTreesX*amountOfTreesZ]; for (int z = 0; z < amountOfTreesZ; z++) { for (int x = 0; x < amountOfTreesX; x++) { TreeInstance treeInstance = new TreeInstance(); treeInstance.prototypeIndex = 0; treeInstance.position = new Vector3(x / (float)amountOfTreesX, 0, z / (float)amountOfTreesZ); treeInstancesArray[(z * amountOfTreesZ) + x] = treeInstance; } } terrainData.treeInstances = treeInstancesArray; // Set up parent object so we can locate the split tiles for cleanup after testing int groupingId = 12345; var parent = new GameObject().AddComponent(); parent.GroupID = groupingId; m_TerrainComponent.transform.SetParent(parent.transform); //Execute the repro steps checking to make sure split terrains have trees Selection.activeGameObject = m_TerrainGO; TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; toolboxWindow.m_TerrainUtilitiesMode.m_Settings.TileSplit = tileSplit; toolboxWindow.m_TerrainUtilitiesMode.SplitTerrains(true); Terrain[] objs = GameObject.FindObjectsOfType(); Terrain[] splitTerrains = objs.Where( obj => obj.terrainData?.treeInstanceCount > 0 ).ToArray(); Assert.IsNotEmpty(splitTerrains); //Cleanup toolboxWindow.Close(); FileUtil.DeleteFileOrDirectory("Assets/Terrain"); File.Delete("Assets/Terrain.meta"); File.Delete(localPath); File.Delete(localPath + ".meta"); UnityEditor.AssetDatabase.Refresh(); } [Test] public void TerrainToolboxUtilities_WhenApplySplatmaps_DoesNotModifyColorData() { //Setup terrain layer data TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(); TerrainLayer layer = new TerrainLayer(); layer.diffuseTexture = CreateGradientTexture(); byte[] texData = layer.diffuseTexture.GetRawTextureData(); m_TerrainComponent.terrainData.terrainLayers = new TerrainLayer[] { layer }; //Reproduce steps Selection.activeObject = m_TerrainGO; TerrainToolboxUtilities utilities = toolboxWindow.m_TerrainUtilitiesMode; utilities.ImportSplatmapsFromTerrain(); //Manually set splatmap list since the window's OnGUI method isn't called which normally sets the splatmap list utilities.m_SplatmapList = new UnityEditorInternal.ReorderableList(utilities.m_Splatmaps, typeof(Texture2D), true, false, true, true); utilities.ExportSplatmapsToTerrain(true); Assert.AreEqual(texData, layer.diffuseTexture.GetRawTextureData()); } int CountTerrainComponents(GameObject go) { return go.GetComponents().Select(x => x.GetType()) .Count(x => x == typeof(Terrain) || x == typeof(TerrainCollider)); } [Test] public void TerrainToolboxUtilities_WhenTerrainsRemoved_AreChildrenRemoved() { // Set up some terrain data var terrainDatas = new TerrainData[3]; for (int i = 0; i < 3; ++i) { terrainDatas[i] = new TerrainData(); terrainDatas[i].heightmapResolution = 256; AssetDatabase.CreateAsset(terrainDatas[i], $"Assets/Test Terrain Data{i}.asset"); } // Set up terrains Texture2D gradientTexture = CreateGradientTexture(); var terrainAlone = Terrain.CreateTerrainGameObject(terrainDatas[0]); var terrainWithOtherComponents = Terrain.CreateTerrainGameObject(terrainDatas[1]); terrainWithOtherComponents.AddComponent(); var terrainWithChild = Terrain.CreateTerrainGameObject(terrainDatas[2]); var child = new GameObject(); child.transform.SetParent(terrainWithChild.transform); AssetDatabase.Refresh(); // Set up the selection with all the terrain objects Selection.objects = new GameObject[] { terrainAlone.gameObject, terrainWithOtherComponents.gameObject, terrainWithChild.gameObject }; //Execute the repro steps checking to make sure split terrains have trees TerrainToolboxWindow toolboxWindow = EditorWindow.GetWindow(typeof(TerrainToolboxWindow)) as TerrainToolboxWindow; toolboxWindow.m_TerrainUtilitiesMode.RemoveTerrains(true); AssetDatabase.Refresh(); // Check the correct gameobjects have been destroyed Assert.IsTrue(terrainAlone == null); Assert.IsFalse(terrainWithOtherComponents == null); Assert.IsFalse(terrainWithChild == null); Assert.IsFalse(child == null); // Check number of terrain components Assert.AreEqual(CountTerrainComponents(terrainWithOtherComponents), 0); Assert.AreEqual(terrainWithOtherComponents.GetComponents().Length - CountTerrainComponents(terrainWithOtherComponents), 2); Assert.AreEqual(CountTerrainComponents(terrainWithChild), 0); // Check children are not destroyed Assert.AreEqual(terrainWithChild.transform.childCount, 1); // clean up GameObject.DestroyImmediate(terrainWithOtherComponents); GameObject.DestroyImmediate(terrainWithChild); toolboxWindow.Close(); } [SetUp] public void Setup() { m_NumTerrains = Terrain.activeTerrains.Length; var terrainData = new TerrainData(); m_TerrainGO = Terrain.CreateTerrainGameObject(terrainData); m_TerrainComponent = m_TerrainGO.GetComponent(); } [TearDown] public void Cleanup() { TerrainGroup group = null; var parent = m_TerrainGO.transform.parent; if (parent != null) { group = parent.GetComponent(); } if (group != null) { var terrains = group.GetComponentsInChildren(); foreach (var t in terrains) { var go = t.gameObject; Object.DestroyImmediate(t.terrainData); Object.DestroyImmediate(t); Object.DestroyImmediate(go); } Object.DestroyImmediate(group.gameObject); } else { Object.DestroyImmediate(m_TerrainComponent.terrainData); Object.DestroyImmediate(m_TerrainComponent); Object.DestroyImmediate(m_TerrainGO); } m_TerrainComponent = null; Selection.activeObject = null; Assert.True(m_NumTerrains == Terrain.activeTerrains.Length, $"Leaked {Terrain.activeTerrains.Length - m_NumTerrains} Terrain objects. Please make sure the test is cleaning up created Terrains."); } /// /// Create a gradient texture /// /// Width of the texture /// Height of the texture /// Texture2D CreateGradientTexture(int width = 513, int height = 513) { Gradient gradient = new Gradient(); GradientColorKey[] colorKeys = { new GradientColorKey{color = Color.white, time = 0f}, new GradientColorKey{color = Color.black, time = 1f}, }; GradientAlphaKey[] alphaKeys = { new GradientAlphaKey{alpha = 1f, time= 0f}, new GradientAlphaKey{alpha = 0f, time= 1f}, }; gradient.SetKeys(colorKeys, alphaKeys); var gradTex = new Texture2D(width, height, TextureFormat.R16, false); gradTex.filterMode = FilterMode.Bilinear; float inv = 1f / (width - 1); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { var t = x * inv; Color col = gradient.Evaluate(t); gradTex.SetPixel(x, y, col); } } gradTex.Apply(); return gradTex; } } }