using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Reflection; using System.Runtime.Serialization.Formatters.Binary; using NUnit.Framework; using UnityEngine; using UnityEngine.Experimental.TerrainAPI; using UnityEngine.TestTools; using static UnityEditor.Experimental.TerrainAPI.BaseBrushUIGroup; namespace UnityEditor.Experimental.TerrainAPI { [TestFixture] public class BrushPlaybackTests { private const string k_TerrainToolsApiPrefix = "UnityEditor.Experimental.TerrainAPI."; const string k_TerrainToolsApiSuffix = ", Unity.TerrainTools.Editor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"; private Terrain terrainObj; private Bounds terrainBounds; private Queue<OnPaintOccurrence> onPaintHistory; private int m_PrevRTHandlesCount; private ulong m_PrevTextureMemory; private Type onSceneGUIContextType, terrainToolType, onPaintType; private static BindingFlags s_bindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.FlattenHierarchy; private float[,] startHeightArr; private object terrainToolInstance; private MethodInfo onPaintMethod, onSceneGUIMethod; private Type baseBrushUIGroupType, brushRotationType, brushSizeType, brushStrengthType; private BaseBrushUIGroup commonUIInstance; private PropertyInfo brushRotationProperty, brushSizeProperty, brushStrengthProperty; private static string GetApiString(string str) { return $"{k_TerrainToolsApiPrefix}{str}{k_TerrainToolsApiSuffix}"; } private void InitTerrainTypesWithReflection(string paintToolName) { terrainToolType = Type.GetType("UnityEditor.Experimental.TerrainAPI." + paintToolName + ", " + "Unity.TerrainTools.Editor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"); onPaintType = Type.GetType("UnityEditor.Experimental.TerrainAPI.OnPaintContext, " + "UnityEditor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"); onSceneGUIContextType = Type.GetType("UnityEditor.Experimental.TerrainAPI.OnSceneGUIContext, " + "UnityEditor, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null"); // Get the method and instance for the current tool being tested PropertyInfo propertyInfo = terrainToolType.GetProperty("instance", s_bindingFlags); MethodInfo methodInfo = propertyInfo.GetGetMethod(); terrainToolInstance = methodInfo.Invoke(null, null); onPaintMethod = terrainToolType.GetMethod("OnPaint"); onSceneGUIMethod = terrainToolType.GetMethod("OnSceneGUI"); MethodInfo loadSettingsInfo = terrainToolType.GetMethod("LoadSettings", s_bindingFlags); if (loadSettingsInfo != null) { loadSettingsInfo.Invoke(terrainToolInstance, null); } // LOAD TOOL SETTINGS baseBrushUIGroupType = typeof(BaseBrushUIGroup); brushSizeType = typeof(IBrushSizeController); brushStrengthType = typeof(IBrushStrengthController); brushRotationType = typeof(IBrushRotationController); FieldInfo baseBrushUIGroupFieldInfo = terrainToolType.GetField("commonUI", BindingFlags.NonPublic | BindingFlags.Instance); if (baseBrushUIGroupFieldInfo == null) { PropertyInfo baseBrushUIGroupPropertyInfo = terrainToolType.GetProperty("commonUI", BindingFlags.NonPublic | BindingFlags.Instance); if (baseBrushUIGroupPropertyInfo != null) { commonUIInstance = baseBrushUIGroupPropertyInfo.GetValue(terrainToolInstance) as BaseBrushUIGroup; } } else { commonUIInstance = baseBrushUIGroupFieldInfo.GetValue(terrainToolInstance) as BaseBrushUIGroup; } if (commonUIInstance == null) { throw new Exception("The commonUI of the brush can't be found - does it have one?"); } brushSizeProperty = baseBrushUIGroupType.GetProperty("brushSize", BindingFlags.Public | BindingFlags.Instance); brushStrengthProperty = baseBrushUIGroupType.GetProperty("brushStrength", BindingFlags.Public | BindingFlags.Instance); brushRotationProperty = baseBrushUIGroupType.GetProperty("brushRotation", BindingFlags.Public | BindingFlags.Instance); } // Triggered once per frame while the test is running void OnSceneGUI(SceneView sceneView) { if (onPaintHistory == null || onPaintHistory.Count == 0 || terrainObj == null) { return; } OnPaintOccurrence paintOccurrence = onPaintHistory.Dequeue(); // Generate a raycast from the relative UV and terrain size Vector3 rayOrigin = new Vector3( Mathf.Lerp(terrainBounds.min.x, terrainBounds.max.x, paintOccurrence.xPos), 1000, Mathf.Lerp(terrainBounds.min.z, terrainBounds.max.z, paintOccurrence.yPos) ); Physics.Raycast(new Ray(rayOrigin, Vector3.down), out RaycastHit hit); Texture brushTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(paintOccurrence.brushTextureAssetPath) as Texture; // Instantiate a null SceneGUIContext with the above raycast object onSceneGUIContextInstance = Activator.CreateInstance( onSceneGUIContextType, null, hit, brushTexture, paintOccurrence.brushStrength, paintOccurrence.brushSize ); // set context info in case tool uses that instead of brush ui group MethodInfo setInfo = onSceneGUIContextType.GetMethod("Set", BindingFlags.Public | BindingFlags.Instance); setInfo.Invoke(onSceneGUIContextInstance, new object[] { sceneView, true, hit, brushTexture, paintOccurrence.brushStrength, paintOccurrence.brushSize }); brushSizeProperty.SetValue(commonUIInstance, paintOccurrence.brushSize); brushStrengthProperty.SetValue(commonUIInstance, paintOccurrence.brushStrength); brushRotationProperty.SetValue(commonUIInstance, paintOccurrence.brushRotation); onSceneGUIMethod.Invoke(terrainToolInstance, new object[] { terrainObj, onSceneGUIContextInstance }); // Set the brush strength via commonUI commonUIInstance.brushStrength = paintOccurrence.brushStrength; commonUIInstance.brushSize = paintOccurrence.brushSize; object onPaintContext = Activator.CreateInstance( onPaintType, hit, brushTexture, new Vector2(paintOccurrence.xPos, paintOccurrence.yPos), paintOccurrence.brushStrength, paintOccurrence.brushSize ); onPaintMethod.Invoke(terrainToolInstance, new object[] { terrainObj, onPaintContext }); } private void ResetTerrainHeight(Terrain terrain) { float[,] heights = GetFullTerrainHeights(terrain); for (int x = 0; x < terrain.terrainData.heightmapResolution; x++) { for (int y = 0; y < terrain.terrainData.heightmapResolution; y++) { heights[x, y] = 0; } } terrain.terrainData.SetHeights(0, 0, heights); } private Queue<OnPaintOccurrence> LoadDataFile(string recordingFileName, bool expectNull = false) { // Discover path to data file string[] assets = AssetDatabase.FindAssets(recordingFileName); if (assets.Length == 0) { Debug.LogError("No asset with name " + recordingFileName + " found"); } string assetPath = AssetDatabase.GUIDToAssetPath(assets[0]); // Load data file as a List<paintHistory> FileStream file = File.OpenRead(assetPath); BinaryFormatter bf = new BinaryFormatter(); Queue<OnPaintOccurrence> paintHistory = new Queue<OnPaintOccurrence>(bf.Deserialize(file) as List<OnPaintOccurrence>); file.Close(); if (paintHistory.Count == 0 && !expectNull) { throw new InconclusiveException("The loaded file contains no recordings"); } return paintHistory; } private float[,] GetFullTerrainHeights(Terrain terrain) { int terrainWidth = terrain.terrainData.heightmapResolution; int terrainHeight = terrain.terrainData.heightmapResolution; return terrain.terrainData.GetHeights( 0, 0, terrainWidth, terrainHeight ); } private bool AreHeightsEqual(float[,] arr1, float[,] arr2) { if(arr1.Rank != arr2.Rank) { return false; } if(arr1.Rank > 1 && arr2.Rank > 1) { if(arr1.GetLength(0) != arr2.GetLength(0) || arr1.GetLength(1) != arr2.GetLength(1)) { return false; } } int xlen = arr1.GetLength(0); int ylen = arr1.GetLength(1); for(int x = 0; x < xlen; ++x) { for(int y = 0; y < ylen; ++y) { if(arr1[x,y] != arr2[x,y]) { return false; } } } return true; } private bool AreHeightsNotEqual(float[,] arr1, float[,] arr2) { return !AreHeightsEqual(arr1, arr2); } public void SetupTerrain(string terrainName) { TerrainData td = new TerrainData(); td.size = new Vector3(1000, 600, 1000); td.heightmapResolution = 513; td.baseMapResolution = 1024; td.SetDetailResolution(1024, 32); // Generate terrain GameObject terrainGo = Terrain.CreateTerrainGameObject(td); terrainObj = terrainGo.GetComponent<Terrain>(); terrainBounds = terrainGo.GetComponent<TerrainCollider>().bounds; Selection.activeObject = terrainGo; ResetTerrainHeight(terrainObj); Selection.activeObject = terrainGo; startHeightArr = GetFullTerrainHeights(terrainObj); } [SetUp] public void SetUp() { EditorWindow.GetWindow<SceneView>().Focus(); m_PrevTextureMemory = Texture.totalTextureMemory; m_PrevRTHandlesCount = RTUtils.GetHandleCount(); // Enables the core brush playback on OnSceneGUI SceneView.duringSceneGui -= OnSceneGUI; SceneView.duringSceneGui += OnSceneGUI; } [TearDown] public void Cleanup() { SceneView.duringSceneGui -= OnSceneGUI; Selection.activeObject = null; if (onPaintHistory != null) onPaintHistory.Clear(); // delete test resources commonUIInstance?.brushMaskFilterStack?.Clear(true); PaintContext.ApplyDelayedActions(); // apply before destroying terrain and terrainData if (terrainObj != null) { UnityEngine.Object.DestroyImmediate(terrainObj.terrainData); UnityEngine.Object.DestroyImmediate(terrainObj.gameObject); } // check Texture memory and RTHandle count // var currentTextureMemory = Texture.totalTextureMemory; // Assert.True(m_PrevTextureMemory == currentTextureMemory, $"Texture memory leak. Was {m_PrevTextureMemory} but is now {currentTextureMemory}. Diff = {currentTextureMemory - m_PrevTextureMemory}"); var currentRTHandlesCount = RTUtils.GetHandleCount(); Assert.True(m_PrevRTHandlesCount == RTUtils.GetHandleCount(), $"RTHandle leak. Was {m_PrevRTHandlesCount} but is now {currentRTHandlesCount}. Diff = {currentRTHandlesCount - m_PrevRTHandlesCount}"); } [UnityTest] [TestCase("PaintHeightHistory", "Terrain", ExpectedResult = null)] public IEnumerator Test_PaintHeight_Playback(string recordingFilePath, string targetTerrainName) { yield return null; SetupTerrain(targetTerrainName); InitTerrainTypesWithReflection("PaintHeightTool"); onPaintHistory = LoadDataFile(recordingFilePath); SetupTerrain(targetTerrainName); while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); yield return null; } if(terrainObj.drawInstanced) { terrainObj.terrainData.SyncHeightmap(); } Assert.That(AreHeightsNotEqual(startHeightArr, GetFullTerrainHeights(terrainObj)), Is.True, "Brush didn't make changes to terrain heightmap"); } [UnityTest] [TestCase("SetHeightHistory", 204f, ExpectedResult = null)] public IEnumerator Test_SetHeight_Playback(string recordingFilePath, float targetHeight) { yield return null; SetupTerrain("Terrain"); InitTerrainTypesWithReflection("SetHeightTool"); onPaintHistory = LoadDataFile(recordingFilePath); // Set the height parameter FieldInfo heightField = terrainToolType.GetField("m_TargetHeight", BindingFlags.NonPublic | BindingFlags.Instance); heightField.SetValue(terrainToolInstance, targetHeight); while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); yield return null; } if(terrainObj.drawInstanced) { terrainObj.terrainData.SyncHeightmap(); } Assert.That(AreHeightsNotEqual(startHeightArr, GetFullTerrainHeights(terrainObj)), Is.True, "Brush didn't make changes to terrain heightmap"); } [UnityTest] [TestCase("StampToolHistory", 500.0f, ExpectedResult = null)] public IEnumerator Test_StampTerrain_Playback(string recordingFilePath, float stampHeight) { yield return null; SetupTerrain("Terrain"); InitTerrainTypesWithReflection("StampTool"); onPaintHistory = LoadDataFile(recordingFilePath); // Set the height parameter FieldInfo propertiesField = terrainToolType.GetField("stampToolProperties", BindingFlags.NonPublic | BindingFlags.Instance); object props = propertiesField.GetValue(terrainToolInstance); FieldInfo heightField = props.GetType().GetField("m_StampHeight"); heightField.SetValue(props, stampHeight); // Use 20 b/c why not while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); yield return null; } if(terrainObj.drawInstanced) { terrainObj.terrainData.SyncHeightmap(); } Assert.That(AreHeightsNotEqual(startHeightArr, GetFullTerrainHeights(terrainObj)), Is.True, "Brush didn't make changes to terrain heightmap"); } [UnityTest] [TestCase("NoiseHeightHistory", "Terrain", ExpectedResult = null)] public IEnumerator Test_PaintNoiseHeight_Playback(string recordingFilePath, string targetTerrainName) { yield return null; SetupTerrain(targetTerrainName); InitTerrainTypesWithReflection("NoiseHeightTool"); onPaintHistory = LoadDataFile(recordingFilePath); while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); yield return null; } if(terrainObj.drawInstanced) { terrainObj.terrainData.SyncHeightmap(); } Assert.That(AreHeightsNotEqual(startHeightArr, GetFullTerrainHeights(terrainObj)), Is.True, "Brush didn't make changes to terrain heightmap"); } // Used to check for texture matrix regressions [UnityTest] [TestCase("NoiseHeightHistory", "Terrain", ExpectedResult = null)] public IEnumerator Test_PaintTexture_Playback(string recordingFilePath, string targetTerrainName) { yield return null; SetupTerrain(targetTerrainName); InitTerrainTypesWithReflection("PaintTextureTool"); onPaintHistory = LoadDataFile(recordingFilePath); TerrainLayer tl1 = new TerrainLayer(), tl2 = new TerrainLayer(); tl1.diffuseTexture = Resources.Load<Texture2D>("testGradientCircle"); tl2.diffuseTexture = Resources.Load<Texture2D>("testGradientCircle"); terrainObj.terrainData.terrainLayers = new TerrainLayer[] { tl1, tl2 }; PaintTextureTool paintTextureTool = terrainToolInstance as PaintTextureTool; FieldInfo selectedTerrainLayerInfo = typeof(PaintTextureTool).GetField("m_SelectedTerrainLayer", s_bindingFlags); selectedTerrainLayerInfo.SetValue(paintTextureTool, tl2); while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); yield return null; } Assert.Pass("Matrix stack regression not found!"); } [UnityTest] [TestCase("PaintHeightHistory", "Terrain", ExpectedResult = null)] public IEnumerator Test_PaintHeight_With_BrushMaskFilters_Playback(string recordingFilePath, string targetTerrainName) { yield return null; InitTerrainTypesWithReflection("PaintHeightTool"); onPaintHistory = LoadDataFile(recordingFilePath); SetupTerrain(targetTerrainName); commonUIInstance.brushMaskFilterStack.Clear(true); var filterCount = FilterUtility.GetFilterTypeCount(); for(int i = 0; i < filterCount; ++i) { commonUIInstance.brushMaskFilterStack.Add(FilterUtility.CreateInstance(FilterUtility.GetFilterType(i))); } while (onPaintHistory.Count > 0) { // Force a SceneView update for OnSceneGUI to be triggered SceneView.RepaintAll(); PaintContext.ApplyDelayedActions(); yield return null; } if(terrainObj.drawInstanced) { terrainObj.terrainData.SyncHeightmap(); } } [UnityTest] public IEnumerator Test_MemoryLeaks() { yield return null; InitTerrainTypesWithReflection("PaintHeightTool"); SetupTerrain("Terrain"); } [UnityTest] public IEnumerator Test_SetHeight_FlattenTile() { yield return null; InitTerrainTypesWithReflection("SetHeightTool"); SetupTerrain("Terrain"); var fillHeightFunc = terrainToolType.GetMethod("Flatten", BindingFlags.Instance | BindingFlags.NonPublic); fillHeightFunc.Invoke(terrainToolInstance, new[] {terrainObj}); } } }