Extending the Terrain Detail Paint Editor?

The Problem

I’ve come across an issue when painting terrains after I upgraded to Unity 2019.3.4. It appears that this version of Unity (and all future versions as far as I’m aware) have adjusted the proportional “strength” of detail painting for terrains and also locked down the target strength setting to set intervals in the inspector. I have documented my findings in this post . This has posed a problem for me in regards to painting grass, as I can no longer paint small amounts of details at the same granularity that I used to.

Here is a comparison to demonstrate what I’m dealing with:

Unity 2019.2:

Unity 2019.3:

Essentially, the problem boils down to the fact that .0625 is now the lowest value allowed for target strength, and the “final” painted strength is much larger than .0625 used to be. In order to paint like I used to, the final paint strength must either be reverted back to how it was before, or the target strength value must become “unlocked” so that I can adjust the strength below .0625 to account for this extra weight.

Potential Solution

I have already submitted a bug report earlier this week (Case 1227935) but have not heard back from the testing team. Of course I don’t blame them considering the virus situation.

So instead of waiting on a Unity update to make an adjustment, my idea is to simply extend the paint tool to use a custom “target strength” value, which will be fully unlocked instead of set into intervals like the value is now. Either that or some sort of multiplier which adjusts the final target strength that is used for the paint operation.

The problem is, I haven’t done much Editor extension, and it appears that there is no way to access this specific piece of Terrain Inspector data. There is a SetDetailLayer() method for TerrainData. But in order to use it effectively I would need to reference the detail prototypes and do a lot of work matching them and adjusting the entire splatmap used by each.

I found this experimental Terrain Paint Tool API, which seems to be way to add new terrain painting tools. But looking through it, it seems like it relies on TerrainPaintUtility which does not include any PaintContext helper method for editing Terrain details, only heightmap, holes, and textures. I’m not sure if it could be used for that purpose, and even if it could I would have to re-implement everything already offered by the detail paint tab–referencing prototypes, opacity painting, etc.

Regardless, both of those options appear to necessitate rebuilding the detail paint tool. It would be much easier if I could just hook into the existing paint tool, but so far I’ve found nothing that could allow that. The Terrain editor UI only appears when the inspector is not in Debug mode, so there’s no way to manually adjust the target strength like that either. I’m open to any suggestions or ideas on how I could go about unlocking this target strength, or adding an additional value to modify the detail paint tool instead of completely rebuilding it. Thanks!

It’s seeming like SetDetailLayer is the only way to interact with the terrain’s detail splatmap. Is this really true? There is no way to duplicate or modify the Terrain’s special editor tools? I don’t think I’ll be able to recreate the entire detail paint tool using the experimental API so this is looking pretty hopeless…

The terrain editor seems mostly done in C#, so maybe you can either use some reflection to hack its internals, or re-implement it using Unity’s reference source?

Thanks, I didn’t realize there was a “PaintTreesDetailsContext” which looks like the class I need to add a detail tool. However, as expected it’s inaccessible due to protection level. I’m not sure if it’s possible to recreate something similar using TerrainPaintUtility, but you’re on to something with re-implementation. However I can’t seem to find the PaintTreesDetailsContext class in the github you linked, and when I peek the definition from VS I get this:

So the full implementation isn’t exposed, which means I can’t reference it to make it again for this new tool. I was able to get a duplicate of the tool to appear in the paint menu though, so that’s a start. The downside is that it has no UI and no way to select the painted detail. Unless it pulls it from the other menu, I’m not really sure. I wasn’t able to really test it since I had to comment out the context class and everything that used it.

Is there some way I could read the PaintTreesDetailsContext class contents so that I can remake it? Otherwise, I’d have to really dig into how it works and see what I can do replicate the effects using what I have, which already sounds like maybe a waste of a ton of time for this kind of issue… this is really making me want to avoid using terrains ever again. I really wish there was some way to just unlock that target strength value without going through all these hoops.

It’s also C#:

It’s just a list of the scene terrains and their UVs to make painting easier. If you look at the actual painting code, it actually uses GetDetailLayer() and SetDetailLayer(), there’s no “secret sauce” to it:

1 Like

Huh… you’re right. I should’ve ran the advanced search in github instead of just searching the file names, no clue why they named it TerrainPaintToolUtility.cs instead of the actual class that’s in it.

Regardless, you are a lifesaver! Thanks to your direction and reference files I was able to painstakingly re-implement the entire Paint Details tool and (almost) everything regarding the Details Inspector UI, which is necessary to select the painted detail and set the target strength. This was all done through a deep dive into the github files and adding each missing method one-by-one as soon as the compiler errors came through. The reason for all the extra additions is because of access level and hidden functions used by UnityEngine internally, so it was necessary to follow down the chain of missing items and re-add them all into this one file.

As a result there’s a lot of horrible organization, tons of broken commented stuff, and some missing features from the real tool. But it works for painting on a selected detail with any chosen target strength. Here’s the code for anyone interested (warning, it’s pretty long and bloated):

using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
using UnityEngine.Experimental.TerrainAPI;

namespace UnityEditor.Experimental.TerrainAPI
{
    internal class PaintTreesDetailsContext
    {
        private static Terrain[] s_Nbrs = new Terrain[8];
        private static Vector2[] s_Uvs = new Vector2[8];

        public Terrain[] terrains = new Terrain[4];
        public Vector2[] uvs = new Vector2[4];

        private PaintTreesDetailsContext() { }

        public static PaintTreesDetailsContext Create(Terrain terrain, Vector2 uv)
        {
            s_Nbrs[0] = terrain.leftNeighbor;
            s_Nbrs[1] = terrain.leftNeighbor ? terrain.leftNeighbor.topNeighbor : (terrain.topNeighbor ? terrain.topNeighbor.leftNeighbor : null);
            s_Nbrs[2] = terrain.topNeighbor;
            s_Nbrs[3] = terrain.rightNeighbor ? terrain.rightNeighbor.topNeighbor : (terrain.topNeighbor ? terrain.topNeighbor.rightNeighbor : null);
            s_Nbrs[4] = terrain.rightNeighbor;
            s_Nbrs[5] = terrain.rightNeighbor ? terrain.rightNeighbor.bottomNeighbor : (terrain.bottomNeighbor ? terrain.bottomNeighbor.rightNeighbor : null);
            s_Nbrs[6] = terrain.bottomNeighbor;
            s_Nbrs[7] = terrain.leftNeighbor ? terrain.leftNeighbor.bottomNeighbor : (terrain.bottomNeighbor ? terrain.bottomNeighbor.leftNeighbor : null);

            s_Uvs[0] = new Vector2(uv.x + 1.0f, uv.y);
            s_Uvs[1] = new Vector2(uv.x + 1.0f, uv.y - 1.0f);
            s_Uvs[2] = new Vector2(uv.x, uv.y - 1.0f);
            s_Uvs[3] = new Vector2(uv.x - 1.0f, uv.y - 1.0f);
            s_Uvs[4] = new Vector2(uv.x - 1.0f, uv.y);
            s_Uvs[5] = new Vector2(uv.x - 1.0f, uv.y + 1.0f);
            s_Uvs[6] = new Vector2(uv.x, uv.y + 1.0f);
            s_Uvs[7] = new Vector2(uv.x + 1.0f, uv.y + 1.0f);

            PaintTreesDetailsContext ctx = new PaintTreesDetailsContext();
            ctx.terrains[0] = terrain;
            ctx.uvs[0] = uv;

            bool left = uv.x < 0.5f;
            bool right = !left;
            bool bottom = uv.y < 0.5f;
            bool top = !bottom;

            int t = 0;
            if (right && top)
                t = 2;
            else if (right && bottom)
                t = 4;
            else if (left && bottom)
                t = 6;

            for (int i = 1; i < 4; ++i, t = (t + 1) % 8)
            {
                ctx.terrains[i] = s_Nbrs[t];
                ctx.uvs[i] = s_Uvs[t];
            }

            return ctx;
        }
    }

    internal class BrushRep
    {
        private const int kMinBrushSize = 3;

        private int m_Size;
        private float[] m_Strength;
        private Texture2D m_OldBrushTex;

        public float GetStrengthInt(int ix, int iy)
        {
            ix = Mathf.Clamp(ix, 0, m_Size - 1);
            iy = Mathf.Clamp(iy, 0, m_Size - 1);

            float s = m_Strength[iy * m_Size + ix];

            return s;
        }

        public void CreateFromBrush(Texture2D brushTex, int size)
        {
            if (size == m_Size && m_OldBrushTex == brushTex && m_Strength != null)
                return;

            Texture2D mask = brushTex;
            if (mask != null)
            {
                Texture2D readableTexture = null;
                if (!mask.isReadable)
                {
                    readableTexture = new Texture2D(mask.width, mask.height, mask.format, mask.mipmapCount > 1);
                    Graphics.CopyTexture(mask, readableTexture);
                    readableTexture.Apply();
                }
                else
                {
                    readableTexture = mask;
                }

                float fSize = size;
                m_Size = size;
                m_Strength = new float[m_Size * m_Size];
                if (m_Size > kMinBrushSize)
                {
                    for (int y = 0; y < m_Size; y++)
                    {
                        float v = y / fSize;
                        for (int x = 0; x < m_Size; x++)
                        {
                            float u = x / fSize;
                            m_Strength[y * m_Size + x] = readableTexture.GetPixelBilinear(u, v).r;
                        }
                    }
                }
                else
                {
                    for (int i = 0; i < m_Strength.Length; i++)
                        m_Strength[i] = 1.0F;
                }

                if (readableTexture != mask)
                    Object.DestroyImmediate(readableTexture);
            }
            else
            {
                m_Strength = new float[1];
                m_Strength[0] = 1.0F;
                m_Size = 1;
            }

            m_OldBrushTex = brushTex;
        }
    }

    internal class PaintDetailsUtils
    {
        public static int FindDetailPrototype(Terrain terrain, Terrain sourceTerrain, int sourceDetail)
        {
            if (sourceDetail == PaintDetailsTool.kInvalidDetail ||
                sourceDetail >= sourceTerrain.terrainData.detailPrototypes.Length)
            {
                return PaintDetailsTool.kInvalidDetail;
            }

            if (terrain == sourceTerrain)
            {
                return sourceDetail;
            }

            DetailPrototype sourceDetailPrototype = sourceTerrain.terrainData.detailPrototypes[sourceDetail];
            for (int i = 0; i < terrain.terrainData.detailPrototypes.Length; ++i)
            {
                if (sourceDetailPrototype.Equals(terrain.terrainData.detailPrototypes[i]))
                    return i;
            }

            return PaintDetailsTool.kInvalidDetail;
        }

        public static int CopyDetailPrototype(Terrain terrain, Terrain sourceTerrain, int sourceDetail)
        {
            DetailPrototype sourceDetailPrototype = sourceTerrain.terrainData.detailPrototypes[sourceDetail];
            DetailPrototype[] newDetailPrototypesArray = new DetailPrototype[terrain.terrainData.detailPrototypes.Length + 1];
            System.Array.Copy(terrain.terrainData.detailPrototypes, newDetailPrototypesArray, terrain.terrainData.detailPrototypes.Length);
            newDetailPrototypesArray[newDetailPrototypesArray.Length - 1] = new DetailPrototype(sourceDetailPrototype);
            terrain.terrainData.detailPrototypes = newDetailPrototypesArray;
            terrain.terrainData.RefreshPrototypes();
            return newDetailPrototypesArray.Length - 1;
        }
    }

    internal class PaintDetailsTool : TerrainPaintTool<PaintDetailsTool>
    {
        public const int kInvalidDetail = -1;

        private DetailPrototype m_LastSelectedDetailPrototype;
        private Terrain m_TargetTerrain;
        private BrushRep m_BrushRep;

        public float detailOpacity { get; set; }
        public float detailStrength { get; set; }
        public int selectedDetail { get; set; }

        public override bool OnPaint(Terrain terrain, IOnPaint editContext)
        {
            if (m_TargetTerrain == null ||
                selectedDetail == kInvalidDetail ||
                selectedDetail >= m_TargetTerrain.terrainData.detailPrototypes.Length)
            {
                return false;
            }

            Texture2D brush = editContext.brushTexture as Texture2D;
            if (brush == null)
            {
                Debug.LogError("Brush texture is not a Texture2D.");
                return false;
            }

            if (m_BrushRep == null)
            {
                m_BrushRep = new BrushRep();
            }

            PaintTreesDetailsContext ctx = PaintTreesDetailsContext.Create(terrain, editContext.uv);

            //PaintContext ctx = PaintContext.CreateFromBounds(terrain, editContext.uv);
          
            for (int t = 0; t < ctx.terrains.Length; ++t)
            {
                Terrain ctxTerrain = ctx.terrains[t];
                if (ctxTerrain != null)
                {
                    int detailPrototype = PaintDetailsUtils.FindDetailPrototype(ctxTerrain, m_TargetTerrain, selectedDetail);
                    if (detailPrototype == kInvalidDetail)
                    {
                        detailPrototype = PaintDetailsUtils.CopyDetailPrototype(ctxTerrain, m_TargetTerrain, selectedDetail);
                    }

                    TerrainData terrainData = ctxTerrain.terrainData;

                    //TerrainPaintUtilityEditor.UpdateTerrainDataUndo(terrainData, "Terrain - Detail Edit");
                    UpdateTerrainDataUndo(terrainData, "Terrain - Detail Edit");

                    int size = (int)Mathf.Max(1.0f, editContext.brushSize * ((float)terrainData.detailResolution / terrainData.size.x));

                    m_BrushRep.CreateFromBrush(brush, size);

                    Vector2 ctxUV = ctx.uvs[t];

                    int xCenter = Mathf.FloorToInt(ctxUV.x * terrainData.detailWidth);
                    int yCenter = Mathf.FloorToInt(ctxUV.y * terrainData.detailHeight);

                    int intRadius = Mathf.RoundToInt(size) / 2;
                    int intFraction = Mathf.RoundToInt(size) % 2;

                    int xmin = xCenter - intRadius;
                    int ymin = yCenter - intRadius;

                    int xmax = xCenter + intRadius + intFraction;
                    int ymax = yCenter + intRadius + intFraction;

                    if (xmin >= terrainData.detailWidth || ymin >= terrainData.detailHeight || xmax <= 0 || ymax <= 0)
                    {
                        continue;
                    }

                    xmin = Mathf.Clamp(xmin, 0, terrainData.detailWidth - 1);
                    ymin = Mathf.Clamp(ymin, 0, terrainData.detailHeight - 1);

                    xmax = Mathf.Clamp(xmax, 0, terrainData.detailWidth);
                    ymax = Mathf.Clamp(ymax, 0, terrainData.detailHeight);

                    int width = xmax - xmin;
                    int height = ymax - ymin;

                    float targetStrength = detailStrength;
                    if (Event.current.shift || Event.current.control)
                        targetStrength = -targetStrength;

                    int[] layers = { detailPrototype };
                    if (targetStrength < 0.0F && !Event.current.control)
                        layers = terrainData.GetSupportedLayers(xmin, ymin, width, height);

                    for (int i = 0; i < layers.Length; i++)
                    {
                        int[,] alphamap = terrainData.GetDetailLayer(xmin, ymin, width, height, layers[i]);

                        for (int y = 0; y < height; y++)
                        {
                            for (int x = 0; x < width; x++)
                            {
                                int xBrushOffset = (xmin + x) - (xCenter - intRadius + intFraction);
                                int yBrushOffset = (ymin + y) - (yCenter - intRadius + intFraction);
                                float opa = detailOpacity * m_BrushRep.GetStrengthInt(xBrushOffset, yBrushOffset);

                                float targetValue = Mathf.Lerp(alphamap[y, x], targetStrength, opa);
                                alphamap[y, x] = Mathf.RoundToInt(targetValue - .5f + Random.value);
                            }
                        }

                        terrainData.SetDetailLayer(xmin, ymin, layers[i], alphamap);
                    }
                }
            }
          

            return false;
        }

        public override void OnEnterToolMode()
        {
            Terrain terrain = null;
            if (Selection.activeGameObject != null)
            {
                terrain = Selection.activeGameObject.GetComponent<Terrain>();
            }

            if (terrain != null &&
                terrain.terrainData != null &&
                m_LastSelectedDetailPrototype != null)
            {
                for (int i = 0; i < terrain.terrainData.detailPrototypes.Length; ++i)
                {
                    if (m_LastSelectedDetailPrototype.Equals(terrain.terrainData.detailPrototypes[i]))
                    {
                        selectedDetail = i;
                        break;
                    }
                }
            }

            m_TargetTerrain = terrain;

            m_LastSelectedDetailPrototype = null;
        }

        public override void OnExitToolMode()
        {
            if (m_TargetTerrain != null &&
                m_TargetTerrain.terrainData != null &&
                selectedDetail != kInvalidDetail &&
                selectedDetail < m_TargetTerrain.terrainData.detailPrototypes.Length)
            {
                m_LastSelectedDetailPrototype = new DetailPrototype(m_TargetTerrain.terrainData.detailPrototypes[selectedDetail]);
            }

            selectedDetail = kInvalidDetail;
        }

        public override string GetName()
        {
            return "Custom Paint Details";
        }

        public override string GetDesc()
        {
            return "Paints the selected detail prototype onto the terrain";
        }


        private static int s_CurrentOperationUndoGroup = -1;
        private static List<UnityEngine.Object> s_CurrentOperationUndoStack = new List<UnityEngine.Object>();

        internal static void UpdateTerrainDataUndo(TerrainData terrainData, string undoName)
        {
            // if we are in a new undo group (new operation) then start with an empty list
            if (Undo.GetCurrentGroup() != s_CurrentOperationUndoGroup)
            {
                s_CurrentOperationUndoGroup = Undo.GetCurrentGroup();
                s_CurrentOperationUndoStack.Clear();
            }

            if (!s_CurrentOperationUndoStack.Contains(terrainData))
            {
                s_CurrentOperationUndoStack.Add(terrainData);
                Undo.RegisterCompleteObjectUndo(terrainData, undoName);
            }
        }
      

        GUIContent[] m_DetailContents;
        float m_Size;

        public override void OnInspectorGUI(Terrain terrain, IOnInspectorGUI editContext)
        {

            EditorGUI.BeginChangeCheck();

            styles = new Styles();

            //EditorGUILayout.BeginVertical();

            LoadDetailIcons();

            /*
            RenderPipelineAsset renderPipelineAsset = GraphicsSettings.currentRenderPipeline;
            if (renderPipelineAsset != null)
            {
                if (SupportedRenderingFeatures.active.terrainDetailUnsupported)
                {
                    EditorGUILayout.HelpBox(styles.detailShadersUnsupported.text, MessageType.Error);
                }
                else if (
                    (renderPipelineAsset.terrainDetailLitShader == null) ||
                    (renderPipelineAsset.terrainDetailGrassShader == null) ||
                    (renderPipelineAsset.terrainDetailGrassBillboardShader == null))
                {
                    EditorGUILayout.HelpBox(styles.detailShadersMissing.text, MessageType.Error);
                }
            }
            */

            //TerrainToolGUIHelper.DrawHeaderFoldoutForBrush();
            //ShowBrushes(0, true, true, false, false, 0);

            // Detail picker
            GUI.changed = false;

            //GUILayout.Label(styles.details, EditorStyles.boldLabel);
            bool doubleClick;
            //PaintDetailsTool.instance.selectedDetail = AspectSelectionGridImageAndText(PaintDetailsTool.instance.selectedDetail, m_DetailContents, 64, styles.gridListText, "No Detail Objects defined", out doubleClick);
            selectedDetail = AspectSelectionGridImageAndText(selectedDetail, m_DetailContents, 64, styles.gridListText, "No Detail Objects defined", out doubleClick);
            if (doubleClick)
            {
                //TerrainDetailContextMenus.EditDetail(new MenuCommand(m_TargetTerrain, PaintDetailsTool.instance.selectedDetail));
                GUIUtility.ExitGUI();
            }

            ShowDetailStats();

            GUILayout.BeginHorizontal();
            GUILayout.FlexibleSpace();
            //MenuButton(styles.editDetails, "CONTEXT/TerrainEngineDetails", PaintDetailsTool.instance.selectedDetail);
            MenuButton(styles.editDetails, "CONTEXT/TerrainEngineDetails", selectedDetail);
            ShowRefreshPrototypes();
            GUILayout.EndHorizontal();

            GUILayout.Label(styles.settings, EditorStyles.boldLabel);

            // Brush size
            m_Size = PowerSlider(styles.brushSize, m_Size, 1.0f, 100.0f, 4.0f);
            detailOpacity = EditorGUILayout.Slider(styles.opacity, detailOpacity, 0, 1);

            // Strength
            detailStrength = EditorGUILayout.Slider(styles.detailTargetStrength, detailStrength, 0, 1);

            //EditorGUILayout.EndVertical();

            //commonUI.OnInspectorGUI(terrain, editContext);

            //var s_showToolControls = TerrainToolGUIHelper.DrawHeaderFoldoutForBrush(Styles.controlHeader, s_showToolControls, () => { m_TargetHeight = 0; });


            /*
            if (s_showToolControls)
            {
                EditorGUILayout.BeginVertical("GroupBox");
                {
#if UNITY_2019_3_OR_NEWER
                    EditorGUI.BeginChangeCheck();
                    m_HeightSpace = (HeightSpace)EditorGUILayout.EnumPopup(Styles.space, m_HeightSpace);
                    if (EditorGUI.EndChangeCheck())
                    {
                        if (m_HeightSpace == HeightSpace.Local)
                            m_TargetHeight = Mathf.Clamp(m_TargetHeight, terrain.GetPosition().y, terrain.terrainData.size.y + terrain.GetPosition().y);
                    }

                    if (m_HeightSpace == HeightSpace.Local)
                    {
                        m_TargetHeight = EditorGUILayout.Slider(Styles.height, m_TargetHeight - terrain.GetPosition().y, 0, terrain.terrainData.size.y) + terrain.GetPosition().y;
                    }
                    else
                    {
                        m_TargetHeight = EditorGUILayout.FloatField(Styles.height, m_TargetHeight);
                    }
#else
                     m_TargetHeight = EditorGUILayout.Slider(Styles.height, m_TargetHeight, 0, terrain.terrainData.size.y);
#endif
                    GUILayout.BeginHorizontal();
                    GUILayout.FlexibleSpace();
                    if (GUILayout.Button(Styles.flatten, GUILayout.ExpandWidth(false)))
                    {
                        Flatten(terrain);
                    }
                    if (GUILayout.Button(Styles.flattenAll, GUILayout.ExpandWidth(false)))
                    {
                        FlattenAll(terrain);
                    }
                    GUILayout.EndHorizontal();

                    if (EditorGUI.EndChangeCheck())
                    {
                        Save(true);
                        SaveSetting();
                    }
                }
                EditorGUILayout.EndVertical();

            }
            */

        }

        public void ShowDetailStats()
        {
            GUILayout.Space(3);

            EditorGUILayout.HelpBox(styles.detailResolutionWarning.text, MessageType.Warning);

            int maxMeshes = m_TargetTerrain.terrainData.detailPatchCount * m_TargetTerrain.terrainData.detailPatchCount;
            EditorGUILayout.LabelField("Detail patches currently allocated: " + maxMeshes);

            //int maxDetails = maxMeshes * PaintDetailsUtils.GetMaxDetailInstances(m_TargetTerrain.terrainData);
            //EditorGUILayout.LabelField("Detail instance density: " + maxDetails);
            GUILayout.Space(3);
        }

        public void MenuButton(GUIContent title, string menuName, int userData)
        {
            GUIContent t = new GUIContent(title.text, styles.settingsIcon, title.tooltip);
            Rect r = GUILayoutUtility.GetRect(t, styles.largeSquare);
            if (GUI.Button(r, t, styles.largeSquare))
            {
                MenuCommand context = new MenuCommand(m_TargetTerrain, userData);
                EditorUtility.DisplayPopupMenu(new Rect(r.x, r.y, 0, 0), menuName, context);
            }
        }

        public void ShowRefreshPrototypes()
        {
            if (GUILayout.Button(styles.refresh, styles.largeSquare))
            {
                //TerrainMenus.RefreshPrototypes();
                m_TargetTerrain.terrainData.RefreshPrototypes();
            }
        }


        void LoadDetailIcons()
        {
            // Locate the proto types asset preview textures
            DetailPrototype[] prototypes = m_TargetTerrain.terrainData.detailPrototypes;
            m_DetailContents = new GUIContent[prototypes.Length];
            for (int i = 0; i < m_DetailContents.Length; i++)
            {
                m_DetailContents[i] = new GUIContent();

                if (prototypes[i].usePrototypeMesh)
                {
                    Texture tex = AssetPreview.GetAssetPreview(prototypes[i].prototype);
                    if (tex != null)
                        m_DetailContents[i].image = tex;

                    if (prototypes[i].prototype != null)
                        m_DetailContents[i].text = prototypes[i].prototype.name;
                    else
                        m_DetailContents[i].text = "Missing";
                }
                else
                {
                    Texture tex = prototypes[i].prototypeTexture;
                    if (tex != null)
                        m_DetailContents[i].image = tex;
                    if (tex != null)
                        m_DetailContents[i].text = tex.name;
                    else
                        m_DetailContents[i].text = "Missing";
                }
            }
        }

        static float PowerSlider(GUIContent content, float value, float minVal, float maxVal, float power, GUILayoutOption[] options = null)
        {
            value = Mathf.Clamp(value, minVal, maxVal);
            EditorGUI.BeginChangeCheck();
            //float newValue = EditorGUILayout.PowerSlider(content, value, minVal, maxVal, power, options);
            float newValue = EditorGUILayout.Slider(value, minVal, maxVal, options);
            if (EditorGUI.EndChangeCheck())
            {
                return newValue;
            }
            return value;
        }

        public static int AspectSelectionGridImageAndText(int selected, GUIContent[] textures, int approxSize, GUIStyle style, string emptyString, out bool doubleClick)
        {
            EditorGUILayout.BeginVertical(GUILayout.MinHeight(10));
            int retval = 0;

            doubleClick = false;

            if (textures.Length != 0)
            {
                int xCount = 0;
                Rect rect = GetBrushAspectRect(textures.Length, approxSize, 12, out xCount);

                Event evt = Event.current;
                if (evt.type == EventType.MouseDown && evt.clickCount == 2 && rect.Contains(evt.mousePosition))
                {
                    doubleClick = true;
                    evt.Use();
                }
                retval = GUI.SelectionGrid(rect, System.Math.Min(selected, textures.Length - 1), textures, xCount, style);
            }
            else
            {
                GUILayout.Label(emptyString);
            }

            GUILayout.EndVertical();
            return retval;
        }

        static Rect GetBrushAspectRect(int elementCount, int approxSize, int extraLineHeight, out int xCount)
        {
            xCount = (int)Mathf.Ceil((EditorGUIUtility.currentViewWidth - 20) / approxSize);
            int yCount = elementCount / xCount;
            if (elementCount % xCount != 0)
                yCount++;
            Rect r1 = GUILayoutUtility.GetAspectRect(xCount / (float)yCount);
            Rect r2 = GUILayoutUtility.GetRect(10, extraLineHeight * yCount);
            r1.height += r2.height;
            return r1;
        }

        class Styles
        {
            public GUIStyle gridListText = "GridListText";
            public GUIStyle largeSquare = new GUIStyle("Button")
            {
                fixedHeight = 22
            };
            public GUIStyle command = "Command";
            public Texture settingsIcon = EditorGUIUtility.IconContent("SettingsIcon").image;

            // List of tools supported by the editor
            public readonly GUIContent[] toolIcons =
            {
                EditorGUIUtility.TrIconContent("TerrainInspector.TerrainToolAdd", "Create Neighbor Terrains"),
                EditorGUIUtility.TrIconContent("TerrainInspector.TerrainToolSplat", "Paint Terrain"),
                EditorGUIUtility.TrIconContent("TerrainInspector.TerrainToolTrees", "Paint Trees"),
                EditorGUIUtility.TrIconContent("TerrainInspector.TerrainToolPlants", "Paint Details"),
                EditorGUIUtility.TrIconContent("TerrainInspector.TerrainToolSettings", "Terrain Settings")
            };

            public readonly GUIContent[] toolNames =
            {
                EditorGUIUtility.TrTextContent("Create Neighbor Terrains", "Click the edges to create neighbor terrains"),
                EditorGUIUtility.TrTextContent("Paint Terrain", "Select a tool from the drop-down list"),
                EditorGUIUtility.TrTextContent("Paint Trees", "Click to paint trees.\n\nHold shift and click to erase trees.\n\nHold Ctrl and click to erase only trees of the selected type."),
                EditorGUIUtility.TrTextContent("Paint Details", "Click to paint details.\n\nHold shift and click to erase details.\n\nHold Ctrl and click to erase only details of the selected type."),
                EditorGUIUtility.TrTextContent("Terrain Settings")
            };

            public readonly GUIContent brushSize = EditorGUIUtility.TrTextContent("Brush Size", "Size of the brush used to paint.");
            public readonly GUIContent opacity = EditorGUIUtility.TrTextContent("Opacity", "Strength of the applied effect.");
            public readonly GUIContent settings = EditorGUIUtility.TrTextContent("Settings");
            //public readonly GUIContent mismatchedTerrainData = EditorGUIUtility.TextContentWithIcon(
            //    "The TerrainData used by the TerrainCollider component is different from this terrain. Would you like to assign the same TerrainData to the TerrainCollider component?",
            //    "console.warnicon");

            public readonly GUIContent assign = EditorGUIUtility.TrTextContent("Assign");
            public readonly GUIContent duplicateTab = EditorGUIUtility.TrTextContent("This inspector tab is not the active Terrain inspector, paint functionality disabled.");
            public readonly GUIContent makeMeActive = EditorGUIUtility.TrTextContent("Activate this inspector");
            public readonly GUIContent gles2NotSupported = EditorGUIUtility.TrTextContentWithIcon("Terrain editting is not supported in GLES2.", MessageType.Info);

            // Trees
            public readonly GUIContent trees = EditorGUIUtility.TrTextContent("Trees");
            public readonly GUIContent editTrees = EditorGUIUtility.TrTextContent("Edit Trees...", "Add/remove tree types.");
            public readonly GUIContent treeDensity = EditorGUIUtility.TrTextContent("Tree Density", "How dense trees are you painting");
            public readonly GUIContent treeHeight = EditorGUIUtility.TrTextContent("Tree Height", "Height of the planted trees");
            public readonly GUIContent treeHeightRandomLabel = EditorGUIUtility.TrTextContent("Random?", "Enable random variation in tree height (variation)");
            public readonly GUIContent treeHeightRandomToggle = EditorGUIUtility.TrTextContent("", "Enable random variation in tree height (variation)");
            public readonly GUIContent lockWidth = EditorGUIUtility.TrTextContent("Lock Width to Height", "Let the tree width be the same with height");
            public readonly GUIContent treeWidth = EditorGUIUtility.TrTextContent("Tree Width", "Width of the planted trees");
            public readonly GUIContent treeWidthRandomLabel = EditorGUIUtility.TrTextContent("Random?", "Enable random variation in tree width (variation)");
            public readonly GUIContent treeWidthRandomToggle = EditorGUIUtility.TrTextContent("", "Enable random variation in tree width (variation)");
            public readonly GUIContent treeColorVar = EditorGUIUtility.TrTextContent("Color Variation", "Amount of random shading applied to trees. This only works if the shader supports _TreeInstanceColor (for example, Speedtree shaders do not use this)");
            public readonly GUIContent treeRotation = EditorGUIUtility.TrTextContent("Random Tree Rotation", "Randomize tree rotation. This only works when the tree has an LOD group.");
            public readonly GUIContent treeRotationDisabled = EditorGUIUtility.TrTextContent("The selected tree does not have an LOD group, so it will use the default impostor system and will not support rotation.");
            public readonly GUIContent massPlaceTrees = EditorGUIUtility.TrTextContent("Mass Place Trees", "The Mass Place Trees button is a very useful way to create an overall covering of trees without painting over the whole landscape. Following a mass placement, you can still use painting to add or remove trees to create denser or sparser areas.");
            public readonly GUIContent treeContributeGI = EditorGUIUtility.TrTextContent("Tree Contribute Global Illumination", "The state of the Contribute GI flag for the tree prefab root GameObject. The flag can be changed on the prefab. When disabled, this tree will not be visible to the lightmapper. When enabled, any child GameObjects which also have the static flag enabled, will be present in lightmap calculations. Regardless of the value of the flag, each tree instance receives its own light probe and no lightmap texels.");

            // Details
            public readonly GUIContent details = EditorGUIUtility.TrTextContent("Details");
            public readonly GUIContent editDetails = EditorGUIUtility.TrTextContent("Edit Details...", "Add/remove detail meshes");
            public readonly GUIContent detailTargetStrength = EditorGUIUtility.TrTextContent("Target Strength", "Target amount");

            // Heightmaps
            public readonly GUIContent textures = EditorGUIUtility.TrTextContent("Texture Resolutions (On Terrain Data)");
            public readonly GUIContent requireResampling = EditorGUIUtility.TrTextContent("Require resampling on change");
            public readonly GUIContent importRaw = EditorGUIUtility.TrTextContent("Import Raw...", "The Import Raw button allows you to set the terrain's heightmap from an image file in the RAW grayscale format. RAW format can be generated by third party terrain editing tools (such as Bryce) and can also be opened, edited and saved by Photoshop. This allows for sophisticated generation and editing of terrains outside Unity.");
            public readonly GUIContent exportRaw = EditorGUIUtility.TrTextContent("Export Raw...", "The Export Raw button allows you to save the terrain's heightmap to an image file in the RAW grayscale format. RAW format can be generated by third party terrain editing tools (such as Bryce) and can also be opened, edited and saved by Photoshop. This allows for sophisticated generation and editing of terrains outside Unity.");

            public readonly GUIContent bakeLightProbesForTrees = EditorGUIUtility.TrTextContent("Bake Light Probes For Trees", "If the option is enabled, Unity will create internal light probes at the position of each tree (these probes are internal and will not affect other renderers in the scene) and apply them to tree renderers for lighting. Otherwise trees are still affected by LightProbeGroups. The option is only effective for trees that have LightProbe enabled on their prototype prefab.");
            public readonly GUIContent deringLightProbesForTrees = EditorGUIUtility.TrTextContent("Remove Light Probe Ringing", "When enabled, removes visible overshooting often observed as ringing on objects affected by intense lighting at the expense of reduced contrast.");
            public readonly GUIContent refresh = EditorGUIUtility.TrTextContent("Refresh", "When you save a tree asset from the modelling app, you will need to click the Refresh button (shown in the inspector when the tree painting tool is selected) in order to see the updated trees on your terrain.");

            // Settings
            public readonly GUIContent basicTerrain = EditorGUIUtility.TrTextContent("Basic Terrain");
            public readonly GUIContent groupingID = EditorGUIUtility.TrTextContent("Grouping ID", "Grouping ID for auto connection");
            public readonly GUIContent allowAutoConnect = EditorGUIUtility.TrTextContent("Auto Connect", "Allow the current terrain tile to automatically connect to neighboring tiles sharing the same grouping ID.");
            public readonly GUIContent attemptReconnect = EditorGUIUtility.TrTextContent("Reconnect", "Will attempt to re-run auto connection");
            public readonly GUIContent drawTerrain = EditorGUIUtility.TrTextContent("Draw", "Toggle the rendering of terrain");
            public readonly GUIContent drawInstancedTerrain = EditorGUIUtility.TrTextContent("Draw Instanced", "Toggle terrain instancing rendering");
            public readonly GUIContent pixelError = EditorGUIUtility.TrTextContent("Pixel Error", "The accuracy of the mapping between the terrain maps (heightmap, textures, etc.) and the generated terrain; higher values indicate lower accuracy but lower rendering overhead.");
            public readonly GUIContent baseMapDist = EditorGUIUtility.TrTextContent("Base Map Dist.", "The maximum distance at which terrain textures will be displayed at full resolution. Beyond this distance, a lower resolution composite image will be used for efficiency.");
            public readonly GUIContent castShadows = EditorGUIUtility.TrTextContent("Cast Shadows", "Does the terrain cast shadows?");
            public readonly GUIContent createMaterial = EditorGUIUtility.TrTextContent("Create...", "Create a new Material asset to be used by the terrain by duplicating the current default Terrain material.");
            public readonly GUIContent reflectionProbes = EditorGUIUtility.TrTextContent("Reflection Probes", "How reflection probes are used on terrain. Only effective when using built-in standard material or a custom material which supports rendering with reflection.");
            //public readonly GUIContent preserveTreePrototypeLayers = EditorGUIUtility.TextContent("Preserve Tree Prototype Layers|Enable this option if you want your tree instances to take on the layer values of their prototype prefabs, rather than the terrain GameObject's layer.");
            public readonly GUIContent treeAndDetails = EditorGUIUtility.TrTextContent("Tree & Detail Objects");
            public readonly GUIContent drawTrees = EditorGUIUtility.TrTextContent("Draw", "Should trees, grass and details be drawn?");
            public readonly GUIContent detailObjectDistance = EditorGUIUtility.TrTextContent("Detail Distance", "The distance (from camera) beyond which details will be culled.");
            public readonly GUIContent detailObjectDensity = EditorGUIUtility.TrTextContent("Detail Density", "The number of detail/grass objects in a given unit of area. The value can be set lower to reduce rendering overhead.");
            public readonly GUIContent treeDistance = EditorGUIUtility.TrTextContent("Tree Distance", "The distance (from camera) beyond which trees will be culled. For SpeedTree trees this parameter is controlled by the LOD group settings.");
            public readonly GUIContent treeBillboardDistance = EditorGUIUtility.TrTextContent("Billboard Start", "The distance (from camera) at which 3D tree objects will be replaced by billboard images. For SpeedTree trees this parameter is controlled by the LOD group settings.");
            public readonly GUIContent treeCrossFadeLength = EditorGUIUtility.TrTextContent("Fade Length", "Distance over which trees will transition between 3D objects and billboards. For SpeedTree trees this parameter is controlled by the LOD group settings.");
            public readonly GUIContent treeMaximumFullLODCount = EditorGUIUtility.TrTextContent("Max Mesh Trees", "The maximum number of visible trees that will be represented as solid 3D meshes. Beyond this limit, trees will be replaced with billboards. For SpeedTree trees this parameter is controlled by the LOD group settings.");
            public readonly GUIContent grassWindSettings = EditorGUIUtility.TrTextContent("Wind Settings for Grass (On Terrain Data)");
            public readonly GUIContent wavingGrassStrength = EditorGUIUtility.TrTextContent("Speed", "The speed of the wind as it blows grass.");
            public readonly GUIContent wavingGrassSpeed = EditorGUIUtility.TrTextContent("Size", "The size of the 'ripples' on grassy areas as the wind blows over them.");
            public readonly GUIContent wavingGrassAmount = EditorGUIUtility.TrTextContent("Bending", "The degree to which grass objects are bent over by the wind.");
            public readonly GUIContent wavingGrassTint = EditorGUIUtility.TrTextContent("Grass Tint", "Overall color tint applied to grass objects.");
            public readonly GUIContent meshResolution = EditorGUIUtility.TrTextContent("Mesh Resolution (On Terrain Data)");
            public readonly GUIContent detailResolutionWarning = EditorGUIUtility.TrTextContent("You may reduce CPU draw call overhead by setting the detail resolution per patch as high as possible, relative to detail resolution.");
            public readonly GUIContent holesSettings = EditorGUIUtility.TrTextContent("Holes Settings (On Terrain Data)");
            public readonly GUIContent holesCompressionToggle = EditorGUIUtility.TrTextContent("Compress Holes Texture", "If enabled, holes texture will be compressed at runtime if compression supported.");
            public readonly GUIContent detailShadersMissing = EditorGUIUtility.TrTextContent("The current render pipeline does not have all Detail shaders");
            public readonly GUIContent detailShadersUnsupported = EditorGUIUtility.TrTextContent("The current render pipeline does not support Detail shaders");


            public static readonly GUIContent renderingLayerMask = EditorGUIUtility.TrTextContent("Rendering Layer Mask", "Mask that can be used with SRP DrawRenderers command to filter renderers outside of the normal layering system.");

            public static readonly GUIContent heightmapResolution = EditorGUIUtility.TrTextContent("Heightmap Resolution", "Pixel resolution of the terrain's heightmap (should be a power of two plus one, eg, 513 = 512 + 1)");
            public static readonly GUIContent[] heightmapResolutionStrings =
            {
                EditorGUIUtility.TrTextContent("33 x 33", "Pixels"),
                EditorGUIUtility.TrTextContent("65 x 65", "Pixels"),
                EditorGUIUtility.TrTextContent("129 x 129", "Pixels"),
                EditorGUIUtility.TrTextContent("257 x 257", "Pixels"),
                EditorGUIUtility.TrTextContent("513 x 513", "Pixels"),
                EditorGUIUtility.TrTextContent("1025 x 1025", "Pixels"),
                EditorGUIUtility.TrTextContent("2049 x 2049", "Pixels"),
                EditorGUIUtility.TrTextContent("4097 x 4097", "Pixels")
            };
            public static readonly int[] heightmapResolutionInts =
            {
                33,
                65,
                129,
                257,
                513,
                1025,
                2049,
                4097
            };

            public static readonly GUIContent alphamapResolution = EditorGUIUtility.TrTextContent("Control Texture Resolution", "Resolution of the \"splatmap\" that controls the blending of the different terrain materials.");
            public static readonly GUIContent[] alphamapResolutionStrings =
            {
                EditorGUIUtility.TrTextContent("16 x 16", "Pixels"),
                EditorGUIUtility.TrTextContent("32 x 32", "Pixels"),
                EditorGUIUtility.TrTextContent("64 x 64", "Pixels"),
                EditorGUIUtility.TrTextContent("128 x 128", "Pixels"),
                EditorGUIUtility.TrTextContent("256 x 256", "Pixels"),
                EditorGUIUtility.TrTextContent("512 x 512", "Pixels"),
                EditorGUIUtility.TrTextContent("1024 x 1024", "Pixels"),
                EditorGUIUtility.TrTextContent("2048 x 2048", "Pixels"),
                EditorGUIUtility.TrTextContent("4096 x 4096", "Pixels")
            };
            public static readonly int[] alphamapResolutionInts =
            {
                16,
                32,
                64,
                128,
                256,
                512,
                1024,
                2048,
                4096
            };

            public static readonly GUIContent basemapResolution = EditorGUIUtility.TrTextContent("Base Texture Resolution", "Resolution of the composite texture used on the terrain when viewed from a distance greater than the Basemap Distance.");
            public static readonly GUIContent[] basemapResolutionStrings =
            {
                EditorGUIUtility.TrTextContent("16 x 16", "Pixels"),
                EditorGUIUtility.TrTextContent("32 x 32", "Pixels"),
                EditorGUIUtility.TrTextContent("64 x 64", "Pixels"),
                EditorGUIUtility.TrTextContent("128 x 128", "Pixels"),
                EditorGUIUtility.TrTextContent("256 x 256", "Pixels"),
                EditorGUIUtility.TrTextContent("512 x 512", "Pixels"),
                EditorGUIUtility.TrTextContent("1024 x 1024", "Pixels"),
                EditorGUIUtility.TrTextContent("2048 x 2048", "Pixels"),
                EditorGUIUtility.TrTextContent("4096 x 4096", "Pixels")
            };
            public static readonly int[] basemapResolutionInts =
            {
                16,
                32,
                64,
                128,
                256,
                512,
                1024,
                2048,
                4096
            };
        }
        static Styles styles;


        public override void OnSceneGUI(Terrain terrain, IOnSceneGUI editContext)
        {
            // We're only doing painting operations, early out if it's not a repaint
            if (Event.current.type != EventType.Repaint)
                return;

            if (editContext.hitValidTerrain)
            {
                BrushTransform brushXform = TerrainPaintUtility.CalculateBrushTransform(terrain, editContext.raycastHit.textureCoord, editContext.brushSize, 0.0f);
                PaintContext ctx = TerrainPaintUtility.BeginPaintHeightmap(terrain, brushXform.GetBrushXYBounds(), 1);
                TerrainPaintUtilityEditor.DrawBrushPreview(ctx, TerrainPaintUtilityEditor.BrushPreview.SourceRenderTexture, editContext.brushTexture, brushXform, TerrainPaintUtilityEditor.GetDefaultBrushPreviewMaterial(), 0);
                TerrainPaintUtility.ReleaseContextResources(ctx);
            }
        }
    }
}

And a few caveats as a result of my hack job:

  • You may only be able to add/remove details from the actual detail tool, so don’t try it from this tool. It’s meant to be a selector only.

  • The brush size slider does nothing. I had no clue how to hook it up to the used brush since it is given as an IOnPaint variable that’s passed into some editor function. I thought maybe the BrushRep variable would have it but I couldn’t find how to get/set the size from it. So for now you just have to set your brush size using the hotkeys - left and right bracket.

  • The styles variable is a huge list of static params which are just for organization when drawing tons of terrain tools. But here you really only need a few variables. I just copied the whole thing to save time. This is in the OnInspectorUI function which I added to draw the UI.

  • It appears as though Undo isn’t working right now, even though I tried to add it in. Even if I can add it though, it may be limited to actions only on a single terrain, that’s my hypothesis anyways. I’m guessing if you paint over to another terrain it may not undo correctly since relies on a stack that gathers actions across multiple tools…? Not really sure, it’s tough figuring out this kind of stuff without a scripting reference. *See Edit

And as a result I’m able to paint at any density I want, for example lower than what is normally allowed in 2019.3:

The screenshot you see above is the custom paint tool UI, with a paint action that shows grass at target density .049!

This is just a quick fix for now since I wanted to share my results. Tomorrow I’ll take another look at it and see if I can get Undo working, as well as brush size slider and maybe proper editing of the details and showing labels, etc. Will update once I make more progress. Thanks again to @Neto_Kokku , you may have just saved me hours of fiddling with the built-in detail paint!

EDIT: Turns out I’m just dumb. Undo is working, I just forgot to uncomment the function call. I’ve edited the code with the fix and now Undo works as expected! Also, the image seems to have trouble loading. Here’s a direct link.

1 Like