Adjust Terrain along a Spline

Unity seems to take their time with the release of the Splines. I’ve had this code for alignment of the terrain along Sebastian Lague’s Path Creator for quite some time and thought I’d share in case anyone else has use for it until the Unity Splines surface which is hopefully soon-ish. Here’s how it looks like:

6079572--659667--splines.gif

The Code:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TerrainAdjusterRuntime : MonoBehaviour
{
    public Terrain terrain;
    public PathCreation.PathCreator pathCreator;
    public float brushFallOff = 0.3f;
    float[,] originalTerrainHeights;

    void Start()
    {
        SaveOriginalTerrainHeights();
    }

    void SaveOriginalTerrainHeights()
    {
        TerrainData terrainData = terrain.terrainData;

        int w = terrainData.heightmapResolution;
        int h = terrainData.heightmapResolution;

        originalTerrainHeights = terrainData.GetHeights(0, 0, w, h);

    }

    void Update()
    {
        if (terrain && pathCreator)
        {
            ShapeTerrain(terrain, pathCreator);
        }
    }

    void ShapeTerrain(Terrain currentTerrain, PathCreation.PathCreator currentPathCreator)
    {

        Vector3 terrainPosition = currentTerrain.gameObject.transform.position;
        TerrainData terrainData = currentTerrain.terrainData;

        // both GetHeights and SetHeights use normalized height values, where 0.0 equals to terrain.transform.position.y in the world space and 1.0 equals to terrain.transform.position.y + terrain.terrainData.size.y in the world space
        // so when using GetHeight you have to manually divide the value by the Terrain.activeTerrain.terrainData.size.y which is the configured height ("Terrain Height") of the terrain.
        float terrainMin = currentTerrain.transform.position.y + 0f;
        float terrainMax = currentTerrain.transform.position.y + currentTerrain.terrainData.size.y;
        float totalHeight = terrainMax - terrainMin;

        int w = terrainData.heightmapResolution;
        int h = terrainData.heightmapResolution;

        // clone the original data, the modifications along the path are based on them
        float[,] allHeights = originalTerrainHeights.Clone() as float[,];

        // the blur radius values being used for the various passes
        int[] initialPassRadii = { 15, 7, 2 };

        for (int pass = 0; pass < initialPassRadii.Length; pass++)
        {
            int radius = initialPassRadii[pass];

            // points as vertices, not equi-distant
            Vector3[] vertexPoints = currentPathCreator.path.vertices;

            // equi-distant points
            List<Vector3> distancePoints = new List<Vector3>();

            // spacing along the array, can speed up the loops
            float arrayIterationSpacing = 1;

            for (float t = 0; t <= currentPathCreator.path.length; t += arrayIterationSpacing)
            {
                Vector3 point = currentPathCreator.path.GetPointAtDistance(t, PathCreation.EndOfPathInstruction.Stop);

                distancePoints.Add(point);
            }

            // sort by height reverse
            // sequential height raising would just lead to irregularities, ie when a higher point follows a lower point
            // we need to proceed from top to bottom height
            distancePoints.Sort((a, b) => -a.y.CompareTo(b.y));

            Vector3[] points = distancePoints.ToArray();

            foreach (var point in points)
            {

                float targetHeight = (point.y - terrainPosition.y) / totalHeight;

                int centerX = (int)(currentPathCreator.transform.position.z + point.z);
                int centerY = (int)(currentPathCreator.transform.position.x + point.x);

                AdjustTerrain(allHeights, radius, centerX, centerY, targetHeight);

            }
        }

        currentTerrain.terrainData.SetHeights(0, 0, allHeights);
    }

    private void AdjustTerrain(float[,] heightMap, int radius, int centerX, int centerY, float targetHeight)
    {
        float deltaHeight = targetHeight - heightMap[centerX, centerY];
        int sqrRadius = radius * radius;

        int width = heightMap.GetLength(0);
        int height = heightMap.GetLength(1);

        for (int offsetY = -radius; offsetY <= radius; offsetY++)
        {
            for (int offsetX = -radius; offsetX <= radius; offsetX++)
            {
                int sqrDstFromCenter = offsetX * offsetX + offsetY * offsetY;

                // check if point is inside brush radius
                if (sqrDstFromCenter <= sqrRadius)
                {
                    // calculate brush weight with exponential falloff from center
                    float dstFromCenter = Mathf.Sqrt(sqrDstFromCenter);
                    float t = dstFromCenter / radius;
                    float brushWeight = Mathf.Exp(-t * t / brushFallOff);

                    // raise terrain
                    int brushX = centerX + offsetX;
                    int brushY = centerY + offsetY;

                    if (brushX >= 0 && brushY >= 0 && brushX < width && brushY < height)
                    {
                        heightMap[brushX, brushY] += deltaHeight * brushWeight;

                        // clamp the height
                        if (heightMap[brushX, brushY] > targetHeight)
                        {
                            heightMap[brushX, brushY] = targetHeight;
                        }
                    }
                }
            }
        }
    }
}

Basically the code iterates along the spline, adjusts the terrain to the height of the spline segment positions and blurs the surrounding terrain.

It might be interesting to create a DOTS version of it. I hope I find the time soon. In case anyone else beats me to it, please do share.

Credits and thanks to Sebastian Lague.

ps: That’s of course only for toying around in play mode (executed in update) and has to be converted to an edit mode feature. But I rather wait for the official Unity Splines for that and other optimizations.

6 Likes

Here’s an updated version that uses the Unity Editor

using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
public class TerrainAdjusterRuntime : MonoBehaviour
{
    public Terrain terrain;

    [Range(0f,1f)]
    public float brushFallOff = 0.3f;

    [Range(1f,10f)]
    public float brushSpacing = 1f;

    [HideInInspector]
    public PathCreation.PathCreator pathCreator;

    private float[,] originalTerrainHeights;

    // the blur radius values being used for the various passes
    public int[] initialPassRadii = { 15, 7, 2 };

    void Start()
    {
        pathCreator = GetComponent<PathCreation.PathCreator>();

        if( pathCreator == null)
            Debug.LogError("Script must be attached to a PathCreator GameObject");

    }

    public void SaveOriginalTerrainHeights()
    {
        if (terrain == null || pathCreator == null)
            return;

        Debug.Log("Saving original terrain data");

        TerrainData terrainData = terrain.terrainData;

        int w = terrainData.heightmapResolution;
        int h = terrainData.heightmapResolution;

        originalTerrainHeights = terrainData.GetHeights(0, 0, w, h);

    }

    public void CleanUp()
    {
        originalTerrainHeights = null;
        Debug.Log("Deleting original terrain data");
    }

    public void ShapeTerrain()
    {
        if (terrain == null || pathCreator == null)
            return;

        // save original terrain in case the terrain got added later
        if (originalTerrainHeights == null)
            SaveOriginalTerrainHeights();

        Vector3 terrainPosition = terrain.gameObject.transform.position;
        TerrainData terrainData = terrain.terrainData;

        // both GetHeights and SetHeights use normalized height values, where 0.0 equals to terrain.transform.position.y in the world space and 1.0 equals to terrain.transform.position.y + terrain.terrainData.size.y in the world space
        // so when using GetHeight you have to manually divide the value by the Terrain.activeTerrain.terrainData.size.y which is the configured height ("Terrain Height") of the terrain.
        float terrainMin = terrain.transform.position.y + 0f;
        float terrainMax = terrain.transform.position.y + terrain.terrainData.size.y;
        float totalHeight = terrainMax - terrainMin;

        int w = terrainData.heightmapResolution;
        int h = terrainData.heightmapResolution;

        // clone the original data, the modifications along the path are based on them
        float[,] allHeights = originalTerrainHeights.Clone() as float[,];

        // the blur radius values being used for the various passes
        for (int pass = 0; pass < initialPassRadii.Length; pass++)
        {
            int radius = initialPassRadii[pass];

            // points as vertices, not equi-distant
            Vector3[] vertexPoints = pathCreator.path.vertices;

            // equi-distant points
            List<Vector3> distancePoints = new List<Vector3>();

            for (float t = 0; t <= pathCreator.path.length; t += brushSpacing)
            {
                Vector3 point = pathCreator.path.GetPointAtDistance(t, PathCreation.EndOfPathInstruction.Stop);

                distancePoints.Add(point);
            }

            // sort by height reverse
            // sequential height raising would just lead to irregularities, ie when a higher point follows a lower point
            // we need to proceed from top to bottom height
            distancePoints.Sort((a, b) => -a.y.CompareTo(b.y));

            Vector3[] points = distancePoints.ToArray();

            foreach (var point in points)
            {

                float targetHeight = (point.y - terrainPosition.y) / totalHeight;

                int centerX = (int)(pathCreator.transform.position.z + point.z);
                int centerY = (int)(pathCreator.transform.position.x + point.x);

                AdjustTerrain(allHeights, radius, centerX, centerY, targetHeight);

            }
        }

        terrain.terrainData.SetHeights(0, 0, allHeights);
    }

    private void AdjustTerrain(float[,] heightMap, int radius, int centerX, int centerY, float targetHeight)
    {
        float deltaHeight = targetHeight - heightMap[centerX, centerY];
        int sqrRadius = radius * radius;

        int width = heightMap.GetLength(0);
        int height = heightMap.GetLength(1);

        for (int offsetY = -radius; offsetY <= radius; offsetY++)
        {
            for (int offsetX = -radius; offsetX <= radius; offsetX++)
            {
                int sqrDstFromCenter = offsetX * offsetX + offsetY * offsetY;

                // check if point is inside brush radius
                if (sqrDstFromCenter <= sqrRadius)
                {
                    // calculate brush weight with exponential falloff from center
                    float dstFromCenter = Mathf.Sqrt(sqrDstFromCenter);
                    float t = dstFromCenter / radius;
                    float brushWeight = Mathf.Exp(-t * t / brushFallOff);

                    // raise terrain
                    int brushX = centerX + offsetX;
                    int brushY = centerY + offsetY;

                    if (brushX >= 0 && brushY >= 0 && brushX < width && brushY < height)
                    {
                        heightMap[brushX, brushY] += deltaHeight * brushWeight;

                        // clamp the height
                        if (heightMap[brushX, brushY] > targetHeight)
                        {
                            heightMap[brushX, brushY] = targetHeight;
                        }
                    }
                }
            }
        }
    }

}

and put this into the Editor folder

using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(TerrainAdjusterRuntime))]
public class TerrainAdjusterRuntimeEditor : Editor
{
    TerrainAdjusterRuntimeEditor editor;

    public void OnEnable()
    {
        this.editor = this;

        TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;

        if (targetGameObject.pathCreator != null)
        {
            targetGameObject.pathCreator.pathUpdated -= OnPathChanged;
            targetGameObject.pathCreator.pathUpdated += OnPathChanged;
        }

        targetGameObject.SaveOriginalTerrainHeights();
    }


    void OnDisable()
    {
        TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;

        // remove original terrain data
        targetGameObject.CleanUp();

        // remove existing listeners
        if (targetGameObject.pathCreator != null)
        {
            targetGameObject.pathCreator.pathUpdated -= OnPathChanged;
        }

    }


    void OnPathChanged()
    {
        TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;

        targetGameObject.ShapeTerrain();
    }

    public override void OnInspectorGUI()
    {
        TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;

        EditorGUI.BeginChangeCheck();

        DrawDefaultInspector();

        if (EditorGUI.EndChangeCheck())
        {
            // if anything (eg falloff) changed recreate the terrain under the path
            OnPathChanged();
        }

        EditorGUILayout.BeginVertical();

        if (GUILayout.Button("Flatten entire terrain"))
        {
            SetTerrainHeight(targetGameObject.terrain, 0f);
        }

        EditorGUILayout.EndVertical();
    }

    void SetTerrainHeight(Terrain terrain, float height)
    {
        TerrainData terrainData = terrain.terrainData;

        int w = terrainData.heightmapResolution;
        int h = terrainData.heightmapResolution;
        float[,] allHeights = terrainData.GetHeights(0, 0, w, h);

        float terrainMin = terrain.transform.position.y + 0f;
        float terrainMax = terrain.transform.position.y + terrain.terrainData.size.y;
        float totalHeight = terrainMax - terrainMin;

        for (int x = 0; x < w; x++)
        {
            for (int y = 0; y < h; y++)
            {
                allHeights[y, x] = 0f;
            }
        }

        terrain.terrainData.SetHeights(0, 0, allHeights);
    }

}

Then assign the TerrainAdjusterRuntime script to the PathCreator gameobject of your scene.

6 Likes

This is awesome!

1 Like

Where did you hear about Unity officially having splines? I don’t see anything on the roadmap.

Splines will be tightly integrated in 2021. Unity didn’t show a roadmap yet, but some of the Unity devs mentioned it on this forum.

There was a Unity Spline tool (or something) in unity repo long time ago as a package, but i can’t find it now.

Edit : Not sure if it was this one though GitHub - Unity-Technologies/SplineToolTutorial

I tried that when I started working on YAPP . That spline tool even came with a blog post of the author. However it never got finished afaik and was rather not working, has slowdowns. So I used a free community solution. At least at the time years ago. Sebastian Lague’s is an excellent solution now. There are others out there as well. Still an in-built one is always preferred.

oh i totally forgot about Yapp

Is there a way to do it on gpu, e.g. create a distance field to the spline and directly adjust the heighmap in a compute shader

Maybe take a look at this:

https://forum.unity.com/threads/wow-terrain-projection-is-super-fast.1005583

If you want the source of that (it’s unfinished, didn’t have the motivation to continue since Unity might come up with their own solution any time), just let me know.

1 Like

I put an example on github which shows how to modify the terrain using projection of a mesh and then blending it with the heightmap:

Here’s how it looks with Sebastian Lague’s Path Creator in use:

4 Likes

Sorry to have to dig up this thread. But Thanks so much Rowlan. You saved me so much time.

3 Likes

Thank you for taking the time to share this. I am trying to find a way to flatten roads on terrain along a spline and this looks like a good start.

I created a new project in unity 2019.4 and installed Bezier Path Creator from the asset store and added these two scripts.

But I keep getting an error on compiling:

Assets/TerrainAdjusterRuntime.cs(84,46): error CS1061: ‘VertexPath’ does not contain a definition for ‘vertices’ and no accessible extension method ‘vertices’ accepting a first argument of type ‘VertexPath’ could be found (are you missing a using directive or an assembly reference?)

Does anyone have any idea how I could solve this?

For anyone else with that issue, you can just delete the line. Not sure why it’s there, but seems to be an artifact from previously showing that non-equidistant points could also be used.

this is really awesome… i want to try it but what is the updated version? i see a code here but also a github link, so i’m a little bit confused

I talked with Jason Booth about it some time ago and he showed how it’s done. What he demonstrated surpassed everything I’d have ever expected. The speed is unparalleled to anything I’ve seen before. One thing lead to the other and now there’s his upcoming asset MicroVerse Roads, he revealed it today:

https://www.youtube.com/watch?v=PI0HSqP78EY

One of the very few roads assets on the Unity Asset Store that comes with Source. The technique is not just some lines of code like I posted above, but I’d rather have an Industry Professional solve Unity’s problems than wasting my time on my own on such kinds of solutions which can have a huge impact.

I should create a video myself, MicroVerse Roads is really good, versatile and super fast :slight_smile:

1 Like

How to specify te size of the projection ?