Search Unity

  1. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Adjust Terrain along a Spline

Discussion in 'World Building' started by Rowlan, Jul 11, 2020.

  1. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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:

    splines.gif

    The Code:

    Code (CSharp):
    1. using System.Collections;
    2. using System.Collections.Generic;
    3. using UnityEngine;
    4.  
    5. public class TerrainAdjusterRuntime : MonoBehaviour
    6. {
    7.     public Terrain terrain;
    8.     public PathCreation.PathCreator pathCreator;
    9.     public float brushFallOff = 0.3f;
    10.     float[,] originalTerrainHeights;
    11.  
    12.     void Start()
    13.     {
    14.         SaveOriginalTerrainHeights();
    15.     }
    16.  
    17.     void SaveOriginalTerrainHeights()
    18.     {
    19.         TerrainData terrainData = terrain.terrainData;
    20.  
    21.         int w = terrainData.heightmapResolution;
    22.         int h = terrainData.heightmapResolution;
    23.  
    24.         originalTerrainHeights = terrainData.GetHeights(0, 0, w, h);
    25.  
    26.     }
    27.  
    28.     void Update()
    29.     {
    30.         if (terrain && pathCreator)
    31.         {
    32.             ShapeTerrain(terrain, pathCreator);
    33.         }
    34.     }
    35.  
    36.     void ShapeTerrain(Terrain currentTerrain, PathCreation.PathCreator currentPathCreator)
    37.     {
    38.  
    39.         Vector3 terrainPosition = currentTerrain.gameObject.transform.position;
    40.         TerrainData terrainData = currentTerrain.terrainData;
    41.  
    42.         // 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
    43.         // 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.
    44.         float terrainMin = currentTerrain.transform.position.y + 0f;
    45.         float terrainMax = currentTerrain.transform.position.y + currentTerrain.terrainData.size.y;
    46.         float totalHeight = terrainMax - terrainMin;
    47.  
    48.         int w = terrainData.heightmapResolution;
    49.         int h = terrainData.heightmapResolution;
    50.  
    51.         // clone the original data, the modifications along the path are based on them
    52.         float[,] allHeights = originalTerrainHeights.Clone() as float[,];
    53.  
    54.         // the blur radius values being used for the various passes
    55.         int[] initialPassRadii = { 15, 7, 2 };
    56.  
    57.         for (int pass = 0; pass < initialPassRadii.Length; pass++)
    58.         {
    59.             int radius = initialPassRadii[pass];
    60.  
    61.             // points as vertices, not equi-distant
    62.             Vector3[] vertexPoints = currentPathCreator.path.vertices;
    63.  
    64.             // equi-distant points
    65.             List<Vector3> distancePoints = new List<Vector3>();
    66.  
    67.             // spacing along the array, can speed up the loops
    68.             float arrayIterationSpacing = 1;
    69.  
    70.             for (float t = 0; t <= currentPathCreator.path.length; t += arrayIterationSpacing)
    71.             {
    72.                 Vector3 point = currentPathCreator.path.GetPointAtDistance(t, PathCreation.EndOfPathInstruction.Stop);
    73.  
    74.                 distancePoints.Add(point);
    75.             }
    76.  
    77.             // sort by height reverse
    78.             // sequential height raising would just lead to irregularities, ie when a higher point follows a lower point
    79.             // we need to proceed from top to bottom height
    80.             distancePoints.Sort((a, b) => -a.y.CompareTo(b.y));
    81.  
    82.             Vector3[] points = distancePoints.ToArray();
    83.  
    84.             foreach (var point in points)
    85.             {
    86.  
    87.                 float targetHeight = (point.y - terrainPosition.y) / totalHeight;
    88.  
    89.                 int centerX = (int)(currentPathCreator.transform.position.z + point.z);
    90.                 int centerY = (int)(currentPathCreator.transform.position.x + point.x);
    91.  
    92.                 AdjustTerrain(allHeights, radius, centerX, centerY, targetHeight);
    93.  
    94.             }
    95.         }
    96.  
    97.         currentTerrain.terrainData.SetHeights(0, 0, allHeights);
    98.     }
    99.  
    100.     private void AdjustTerrain(float[,] heightMap, int radius, int centerX, int centerY, float targetHeight)
    101.     {
    102.         float deltaHeight = targetHeight - heightMap[centerX, centerY];
    103.         int sqrRadius = radius * radius;
    104.  
    105.         int width = heightMap.GetLength(0);
    106.         int height = heightMap.GetLength(1);
    107.  
    108.         for (int offsetY = -radius; offsetY <= radius; offsetY++)
    109.         {
    110.             for (int offsetX = -radius; offsetX <= radius; offsetX++)
    111.             {
    112.                 int sqrDstFromCenter = offsetX * offsetX + offsetY * offsetY;
    113.  
    114.                 // check if point is inside brush radius
    115.                 if (sqrDstFromCenter <= sqrRadius)
    116.                 {
    117.                     // calculate brush weight with exponential falloff from center
    118.                     float dstFromCenter = Mathf.Sqrt(sqrDstFromCenter);
    119.                     float t = dstFromCenter / radius;
    120.                     float brushWeight = Mathf.Exp(-t * t / brushFallOff);
    121.  
    122.                     // raise terrain
    123.                     int brushX = centerX + offsetX;
    124.                     int brushY = centerY + offsetY;
    125.  
    126.                     if (brushX >= 0 && brushY >= 0 && brushX < width && brushY < height)
    127.                     {
    128.                         heightMap[brushX, brushY] += deltaHeight * brushWeight;
    129.  
    130.                         // clamp the height
    131.                         if (heightMap[brushX, brushY] > targetHeight)
    132.                         {
    133.                             heightMap[brushX, brushY] = targetHeight;
    134.                         }
    135.                     }
    136.                 }
    137.             }
    138.         }
    139.     }
    140. }
    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.
     
    Last edited: Jul 11, 2020
  2. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    Here's an updated version that uses the Unity Editor

    Code (CSharp):
    1. using System.Collections.Generic;
    2. using UnityEngine;
    3.  
    4. [ExecuteInEditMode]
    5. public class TerrainAdjusterRuntime : MonoBehaviour
    6. {
    7.     public Terrain terrain;
    8.  
    9.     [Range(0f,1f)]
    10.     public float brushFallOff = 0.3f;
    11.  
    12.     [Range(1f,10f)]
    13.     public float brushSpacing = 1f;
    14.  
    15.     [HideInInspector]
    16.     public PathCreation.PathCreator pathCreator;
    17.  
    18.     private float[,] originalTerrainHeights;
    19.  
    20.     // the blur radius values being used for the various passes
    21.     public int[] initialPassRadii = { 15, 7, 2 };
    22.  
    23.     void Start()
    24.     {
    25.         pathCreator = GetComponent<PathCreation.PathCreator>();
    26.  
    27.         if( pathCreator == null)
    28.             Debug.LogError("Script must be attached to a PathCreator GameObject");
    29.  
    30.     }
    31.  
    32.     public void SaveOriginalTerrainHeights()
    33.     {
    34.         if (terrain == null || pathCreator == null)
    35.             return;
    36.  
    37.         Debug.Log("Saving original terrain data");
    38.  
    39.         TerrainData terrainData = terrain.terrainData;
    40.  
    41.         int w = terrainData.heightmapResolution;
    42.         int h = terrainData.heightmapResolution;
    43.  
    44.         originalTerrainHeights = terrainData.GetHeights(0, 0, w, h);
    45.  
    46.     }
    47.  
    48.     public void CleanUp()
    49.     {
    50.         originalTerrainHeights = null;
    51.         Debug.Log("Deleting original terrain data");
    52.     }
    53.  
    54.     public void ShapeTerrain()
    55.     {
    56.         if (terrain == null || pathCreator == null)
    57.             return;
    58.  
    59.         // save original terrain in case the terrain got added later
    60.         if (originalTerrainHeights == null)
    61.             SaveOriginalTerrainHeights();
    62.  
    63.         Vector3 terrainPosition = terrain.gameObject.transform.position;
    64.         TerrainData terrainData = terrain.terrainData;
    65.  
    66.         // 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
    67.         // 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.
    68.         float terrainMin = terrain.transform.position.y + 0f;
    69.         float terrainMax = terrain.transform.position.y + terrain.terrainData.size.y;
    70.         float totalHeight = terrainMax - terrainMin;
    71.  
    72.         int w = terrainData.heightmapResolution;
    73.         int h = terrainData.heightmapResolution;
    74.  
    75.         // clone the original data, the modifications along the path are based on them
    76.         float[,] allHeights = originalTerrainHeights.Clone() as float[,];
    77.  
    78.         // the blur radius values being used for the various passes
    79.         for (int pass = 0; pass < initialPassRadii.Length; pass++)
    80.         {
    81.             int radius = initialPassRadii[pass];
    82.  
    83.             // points as vertices, not equi-distant
    84.             Vector3[] vertexPoints = pathCreator.path.vertices;
    85.  
    86.             // equi-distant points
    87.             List<Vector3> distancePoints = new List<Vector3>();
    88.  
    89.             for (float t = 0; t <= pathCreator.path.length; t += brushSpacing)
    90.             {
    91.                 Vector3 point = pathCreator.path.GetPointAtDistance(t, PathCreation.EndOfPathInstruction.Stop);
    92.  
    93.                 distancePoints.Add(point);
    94.             }
    95.  
    96.             // sort by height reverse
    97.             // sequential height raising would just lead to irregularities, ie when a higher point follows a lower point
    98.             // we need to proceed from top to bottom height
    99.             distancePoints.Sort((a, b) => -a.y.CompareTo(b.y));
    100.  
    101.             Vector3[] points = distancePoints.ToArray();
    102.  
    103.             foreach (var point in points)
    104.             {
    105.  
    106.                 float targetHeight = (point.y - terrainPosition.y) / totalHeight;
    107.  
    108.                 int centerX = (int)(pathCreator.transform.position.z + point.z);
    109.                 int centerY = (int)(pathCreator.transform.position.x + point.x);
    110.  
    111.                 AdjustTerrain(allHeights, radius, centerX, centerY, targetHeight);
    112.  
    113.             }
    114.         }
    115.  
    116.         terrain.terrainData.SetHeights(0, 0, allHeights);
    117.     }
    118.  
    119.     private void AdjustTerrain(float[,] heightMap, int radius, int centerX, int centerY, float targetHeight)
    120.     {
    121.         float deltaHeight = targetHeight - heightMap[centerX, centerY];
    122.         int sqrRadius = radius * radius;
    123.  
    124.         int width = heightMap.GetLength(0);
    125.         int height = heightMap.GetLength(1);
    126.  
    127.         for (int offsetY = -radius; offsetY <= radius; offsetY++)
    128.         {
    129.             for (int offsetX = -radius; offsetX <= radius; offsetX++)
    130.             {
    131.                 int sqrDstFromCenter = offsetX * offsetX + offsetY * offsetY;
    132.  
    133.                 // check if point is inside brush radius
    134.                 if (sqrDstFromCenter <= sqrRadius)
    135.                 {
    136.                     // calculate brush weight with exponential falloff from center
    137.                     float dstFromCenter = Mathf.Sqrt(sqrDstFromCenter);
    138.                     float t = dstFromCenter / radius;
    139.                     float brushWeight = Mathf.Exp(-t * t / brushFallOff);
    140.  
    141.                     // raise terrain
    142.                     int brushX = centerX + offsetX;
    143.                     int brushY = centerY + offsetY;
    144.  
    145.                     if (brushX >= 0 && brushY >= 0 && brushX < width && brushY < height)
    146.                     {
    147.                         heightMap[brushX, brushY] += deltaHeight * brushWeight;
    148.  
    149.                         // clamp the height
    150.                         if (heightMap[brushX, brushY] > targetHeight)
    151.                         {
    152.                             heightMap[brushX, brushY] = targetHeight;
    153.                         }
    154.                     }
    155.                 }
    156.             }
    157.         }
    158.     }
    159.  
    160. }
    and put this into the Editor folder

    Code (CSharp):
    1. using UnityEngine;
    2. using UnityEditor;
    3.  
    4. [CustomEditor(typeof(TerrainAdjusterRuntime))]
    5. public class TerrainAdjusterRuntimeEditor : Editor
    6. {
    7.     TerrainAdjusterRuntimeEditor editor;
    8.  
    9.     public void OnEnable()
    10.     {
    11.         this.editor = this;
    12.  
    13.         TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;
    14.  
    15.         if (targetGameObject.pathCreator != null)
    16.         {
    17.             targetGameObject.pathCreator.pathUpdated -= OnPathChanged;
    18.             targetGameObject.pathCreator.pathUpdated += OnPathChanged;
    19.         }
    20.  
    21.         targetGameObject.SaveOriginalTerrainHeights();
    22.     }
    23.  
    24.  
    25.     void OnDisable()
    26.     {
    27.         TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;
    28.  
    29.         // remove original terrain data
    30.         targetGameObject.CleanUp();
    31.  
    32.         // remove existing listeners
    33.         if (targetGameObject.pathCreator != null)
    34.         {
    35.             targetGameObject.pathCreator.pathUpdated -= OnPathChanged;
    36.         }
    37.  
    38.     }
    39.  
    40.  
    41.     void OnPathChanged()
    42.     {
    43.         TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;
    44.  
    45.         targetGameObject.ShapeTerrain();
    46.     }
    47.  
    48.     public override void OnInspectorGUI()
    49.     {
    50.         TerrainAdjusterRuntime targetGameObject = (TerrainAdjusterRuntime)target;
    51.  
    52.         EditorGUI.BeginChangeCheck();
    53.  
    54.         DrawDefaultInspector();
    55.  
    56.         if (EditorGUI.EndChangeCheck())
    57.         {
    58.             // if anything (eg falloff) changed recreate the terrain under the path
    59.             OnPathChanged();
    60.         }
    61.  
    62.         EditorGUILayout.BeginVertical();
    63.  
    64.         if (GUILayout.Button("Flatten entire terrain"))
    65.         {
    66.             SetTerrainHeight(targetGameObject.terrain, 0f);
    67.         }
    68.  
    69.         EditorGUILayout.EndVertical();
    70.     }
    71.  
    72.     void SetTerrainHeight(Terrain terrain, float height)
    73.     {
    74.         TerrainData terrainData = terrain.terrainData;
    75.  
    76.         int w = terrainData.heightmapResolution;
    77.         int h = terrainData.heightmapResolution;
    78.         float[,] allHeights = terrainData.GetHeights(0, 0, w, h);
    79.  
    80.         float terrainMin = terrain.transform.position.y + 0f;
    81.         float terrainMax = terrain.transform.position.y + terrain.terrainData.size.y;
    82.         float totalHeight = terrainMax - terrainMin;
    83.  
    84.         for (int x = 0; x < w; x++)
    85.         {
    86.             for (int y = 0; y < h; y++)
    87.             {
    88.                 allHeights[y, x] = 0f;
    89.             }
    90.         }
    91.  
    92.         terrain.terrainData.SetHeights(0, 0, allHeights);
    93.     }
    94.  
    95. }
    96.  
    Then assign the TerrainAdjusterRuntime script to the PathCreator gameobject of your scene.
     
    protopop, yokaiju and WhaleForge like this.
  3. wyattt_

    wyattt_

    Unity Technologies

    Joined:
    May 9, 2018
    Posts:
    423
    This is awesome!
     
    Rowlan likes this.
  4. jRocket

    jRocket

    Joined:
    Jul 12, 2012
    Posts:
    699
    Where did you hear about Unity officially having splines? I don't see anything on the roadmap.
     
  5. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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.
     
  6. Reanimate_L

    Reanimate_L

    Joined:
    Oct 10, 2009
    Posts:
    2,788
  7. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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.
     
  8. Reanimate_L

    Reanimate_L

    Joined:
    Oct 10, 2009
    Posts:
    2,788
    oh i totally forgot about Yapp
     
  9. nasos_333

    nasos_333

    Joined:
    Feb 13, 2013
    Posts:
    13,205
    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
     
  10. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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.
     
    protopop likes this.
  11. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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:

    https://github.com/Roland09/TerrainAlign

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

     
  12. theonerm2_unity

    theonerm2_unity

    Joined:
    Sep 7, 2018
    Posts:
    130
    Sorry to have to dig up this thread. But Thanks so much Rowlan. You saved me so much time.
     
    wyattt_, TerraUnity and Rowlan like this.
  13. protopop

    protopop

    Joined:
    May 19, 2009
    Posts:
    1,557
    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?

    Screen Shot 2022-05-12 at 5.29.01 PM.png
     
  14. TreyH

    TreyH

    Joined:
    Feb 6, 2014
    Posts:
    15
    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.
     
  15. impheris

    impheris

    Joined:
    Dec 30, 2009
    Posts:
    1,582
    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
     
  16. Rowlan

    Rowlan

    Joined:
    Aug 4, 2016
    Posts:
    4,087
    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:



    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 :)