Creating a dynamically changing landscape for RTS on Unity3D

Once upon a time, I had the joy of playing the wonderful RTS called "Perimeter: Geometry Warriors" from the domestic developer KD Labs. This is a game about how huge flying cities called “Frames” plow open spaces of “Sponge” - a chain of interconnected worlds. The plot is rather strange and abstract, but much more interesting and innovative component of the game was one of its technical features, and not the plot. Unlike most RTS, where battles take place on static terrain, in Perimeter, one of the key game mechanics was terraforming. The player had the means to manipulate the landscape in order to erect his structures on it, as well as a whole arsenal of military units that could turn this landscape into cracked, swam and spewing red-hot stones / nasty insects.

As you know, the world of RTS is now experiencing some decline. Indie developers are too busy riveting retro platformers and rouge-like games of furious complexity, and therefore, after playing in the "Perimeter" some time ago, I decided that I myself should try to implement something similar - the idea was interesting with technical and gameplay points of view. Having some practical experience in game development (I previously tried to do something on XNA), I thought that in order to achieve at least some success alone, I would have to use something higher-level and simple. My choice fell on Unity 3D, whose fifth version was just out of the press.

Armed with a car of enthusiasm, inspiration from the just completed “Perimeter” and a watched series of video tutorials on Unity, I began to sketch and get acquainted with the tools that Unity Editor offered me.

What community offers


As always, my first pancake came out lumpy. Without thinking enough, I began to implement the landscape using a plane and code that was supposed to raise or lower the vertices of this plane. Many readers, at least a little familiar with Unity, may object: “But Unity has a Terrain component designed specifically for this purpose!” - and they will be right. The only problem is that, being too keen on implementing my idea, I forgot about one important thing: RTFM! If I had studied the documentation and the forums a little more carefully, I would not have solved the problem in such an openly stupid way, but would have immediately used the ready-made component.

After two days of useless sweat and algorithms (the Plane was clearly not intended to be used for such purposes), I started to make terrain using Terrain. I must say that among individual members of the Unity community, the idea was to create a dynamic landscape for their game. Some people asked questions on the forums and received answers. They were recommended to use the SetHeights method, which receives a piece of normalized from 0f to 1f heightmap, which will be set starting from the point (xBase; yBase) on the selected landscape.

Satisfied with the results of my searches, I started development. The first working prototype was ready a couple of hours later and included the simplest camera engine (using the right key), a simple crater generator and actually adding these craters to the landscape by pressing the left key (you can grab the build and see it here ).

The deforming part itself was indecently simple.

It was a script like this
void ApplyAt(DeformationData data) {
    currentHeights = data.terrainData.GetHeights(data.X - data.W/2, data.Y - data.H/2, W, H);
    for (int i = 0; i < data.W; i++) {
        for (int j = 0; j < data.H; j++) {
            if (data.Type == DeformationType.Additive)
                currentHeights[i, j] += data.heightmap[i, j];
            else
                currentHeights[i, j] *= data.heightmap[i, j];
        }
    }
    data.terrainData.SetHeights(data.X - data.W/2, data.Y - data.H/2, currentHeights);
}


The DeformationData object contained the X and Y coordinates to which the deformation should be applied, the normalized heightmap, which additively or multiplicatively overlaps the current terrain and the other boilerplate needed for the deformation mechanism to work.

There was also a strain generator, allowing, for example,

generate a crater according to the given parameters
// Методы являются частью класса  - переменные H, W являются его свойствами.
// Несложно догадаться, что H - высота матрицы деформации, W - ее ширина.
float GetDepth(float distanceToCenter, int holeDepth,
                       int wallsHeight, int holeRadius, int craterRadius)
{
    if (distanceToCenter <= holeRadius)
    {
        return (Mathf.Pow(distanceToCenter, 2) * (holeDepth + wallsHeight) / Mathf.Pow(holeRadius, 2)) - holeDepth;
    } else if (distanceToCenter <= craterRadius) {
        return Mathf.Pow(craterRadius - distanceToCenter, 2) * wallsHeight / Mathf.Pow(craterRadius - holeRadius, 2);
    } else return 0f;
}
float[,] Generate(int holeDepth, int wallsHeight,
                                   int holeRadius, int craterRadius)
{
    var heightmap = new float[W, H];
    for (var x = 0; x < W; x++)
    {
        for (var y = 0; y < H; y++)
        {
            var offsetX = x - W / 2;
            var offsetY = y - H / 2;
            var depth = GetDepth(Mathf.Sqrt(offsetX * offsetX + offsetY * offsetY),
                                holeDepth, wallsHeight, holeRadius, craterRadius);
	    heightmap[x, y] = depth;
        }
    }
}


And all this was the basis of all Tech Demo, so to speak, of course.

Analysis of the results of the first attempt


If you looked at Tech Demo, you probably immediately noticed certain problems in the deformation mechanism. If you did not watch it (for which I do not blame you), then I will tell you what was wrong. The main problem was performance. More precisely, its complete absence. When the deformation began, framerate fell to very small (on some machines unique) numbers, which was unacceptable, because there was essentially no graphics in the game. I managed to find out that the SetHeights () method itself causes a very complicated series of LOD calculations for the landscape and for this reason is not suitable for real-time terrain deformations. It would seem that my hopes have collapsed and the implementation of real-time deformations on Unity is impossible, but I did not give up and found out an obvious, but very important feature of the LOD recalculation mechanism.

The lower the resolution of the terrain elevation map, the smaller the impact on performance when using SetHeights ().

Resolution of the height map is a parameter that characterizes the quality of the landscape display. It is integer (obviously), which is why in the snippets above integer variables were used to indicate the coordinates on the map. And it can be larger than the size of the landscape, for example, for a landscape of 256x256, you can set the resolution of the height map to 513, which will give the landscape accuracy and less angular outlines. Why exactly 513, and not 512, I will tell in the next section.

Games with a resolution of the height map allowed me to find more or less optimal sizes for my configuration, but I was very disappointed with the results. For the successful application of such a landscape in RTS, its size must be large enough so that at least two players can coexist for some time on it. According to my initial estimates, a 2x2km (or 2048x2048 Unity Units) card should have been just right. In order not to notice the effect on the framerate of deformations in real time, the size of the landscape should have been no more than 512x512 units. Moreover, the single accuracy of the height map gave not the most impressive results when it came to visual quality. The landscape was angular and crooked in places, which required doubling the accuracy of height maps.

In general, things were not very good.

Super Terrain - concept and theory


Маленькая заметка: в этой секции рассматривается теоретическая и концептуальная части Super Terrain. Код реализации рассматривается в деталях в следующей секции.

Around that time, the following thought began to visit me: “Since we cannot make one large landscape and still have sufficient performance in deformations, why not make a bunch of small ones and place them side by side? “Like chunk in Minecraft?” The idea was not bad, but it was associated with some problems:

  • How to choose a size for "chunk"
  • How to make sure that there are no noticeable seams at the joints of the chunk
  • How to apply deformations that occur at the joints of neighboring chunk'ov

First problem

The first problem was rather trivial: I just chose the size for the chunk 256x256 with double precision (heightmap resolution = 513). This setup did not cause performance problems on my machine . Perhaps in the future we would have to reconsider the sizes of chunk's, but at the current stage, such a solution suited me.

Second problem

As for the second problem, it had two components. The first, obviously, was to even out the heights of the neighboring “pixels” of the height charts of the neighboring chunk. It was during the solution of this problem that I understood why the resolution of the height map is a power of two + 1. I will demonstrate in the illustration:



Obviously, in order to maintain the same height in neighboring landscapes, the “last” pixel of the height map of the first landscape must be in height is equal to the “first” pixel of the following:



Obviously, “Super Terrain” - this is the matrix of Unity Terrain'ov, united by a mechanism for applying heightmaps and deformations.

After the implementation of the code for combining landscapes was completed (the use of local deformations of small size was left for later - now it was necessary to develop a mechanism for creating the Terrain matrix and initial initialization of the elevation map), another component of the second problem (lack of seams at the junction of landscapes) ), which fortunately was resolved quite simply. The problem was that for such a “combination” of landscapes, it was necessary to explain to Unity that they were shared and neighboring. To do this, the developers provided the SetHeighbors method . I still don’t quite understand how it works, but without it artifacts with shadows and dark stripes appear at the junction of landscapes.

Third problem

The most interesting and difficult of the three, this problem did not give me rest for at least a week. I dropped four different implementations until I came to the final one, which I will talk about. Immediately I will make a small remark about one important limitation of my implementation - it assumes that local deformation cannot be larger than one chunk. Deformation can still be at their junction, however, the side of the deformation matrix should not exceed the resolution of the chunk's height map and all of them should be square (height maps, chunk's and deformations themselves). In general, this is not a big limitation, since any large deformation can be obtained by applying several small ones in turn. As for the "squareness", this is a limitation of the height map. That is, only its height map is required to be square,

The idea of ​​an algorithm for the universal application of deformation itself was as follows:

  1. Divide the deformation heightmap into nine parts, one for each of the chunk's that the deformation could potentially affect. So the central part will be responsible for the deformation of the chunk directly hit by the deformation, the parties are responsible for the chunk left, right or top / bottom, etc. If the deformation does not change the chunk, then its component will be equal to null.
  2. Apply partial heightmaps to the corresponding chunk, or ignore the changes if the partial heightmap is null.

This approach allows for universal application of deformations located both in the very center of the chunk and not affecting other chunk, as well as at the borders with them or even in corners. It is necessary to divide precisely into nine parts (and not into four) due to the fact that if the deformation begins or ends at the border of the chunk, the boundary pixels of the one adjacent to it must also be changed. (In order that there were no visible seams - see the solution to problem number 2).

Super Terrain - Practice


Create SuperTerrain

As part of the second problem, a mesanism was developed that allows you to combine several Terrain'ov into one and apply the "global" height maps that fit the entire landscape.

I will say right away that I needed this possibility of global use of the height map because it was procedural to create the landscape, and the Square-Diamond algorithm was used to use it , the output of which was a large matrix of floats — our large height map.

In general, the creation of SuperTerrain is a fairly simple and intuitive process described

here
/// 
/// Compound terrain object.
/// 
public class SuperTerrain 
{
    /// 
    /// Contains the array of subterrain objects
    /// 
    private Terrain[,] subterrains;
    /// 
    /// Superterrain detail. The resulting superterrain is 2^detail terrains.
    /// 
    /// The detail.
    public int Detail { get; private set; }
    /// 
    /// Parent gameobject to nest created terrains into.
    /// 
    /// The parent.
    public Transform Parent { get; private set; }
    /// 
    /// Builds the new terrain object.
    /// 
    /// The new terrain.
    private Terrain BuildNewTerrain()
    {
        // Using this divisor because of internal workings of the engine.
        // The resulting terrain is still going to be subterrain size.
        var divisor = GameplayConstants.SuperTerrainHeightmapResolution / GameplayConstants.SubterrainSize * 2;
        var terrainData = new TerrainData {
            size = new Vector3 (GameplayConstants.SubterrainSize / divisor,
                                            GameplayConstants.WorldHeight,
                                            GameplayConstants.SubterrainSize / divisor),
	    heightmapResolution = GameplayConstants.SuperTerrainHeightmapResolution
        };
        var newTerrain = Terrain.CreateTerrainGameObject(terrainData).GetComponent();
        newTerrain.transform.parent = Parent;
        newTerrain.transform.gameObject.layer = GameplayConstants.TerrainLayer;
        newTerrain.heightmapPixelError = GameplayConstants.SuperTerrainPixelError;
        return newTerrain;
    }
    /// 
    /// Initializes the terrain array and moves the terrain transforms to match their position in the array.
    /// 
    private void InitializeTerrainArray()
    {
        subterrains = new Terrain[Detail, Detail];
	for (int x = 0; x < Detail; x++) {
	    for (int y = 0; y < Detail; y++) {
		subterrains[y, x] = BuildNewTerrain();
		subterrains[y, x].transform.Translate(new Vector3(x * GameplayConstants.SubterrainSize,
			                                                                       0f,
			                                                                       y * GameplayConstants.SubterrainSize));
	    }
	}
    }
    /// 
    /// Initializes a new instance of the  class.
    /// 
    /// Superterrain detail. The resultsing superterrain is 2^detail terrains.
    /// Parent gameobject to nest created terrains into.
    public SuperTerrain(int detail, Transform parent)
    {
        Detail = detail;
	Parent = parent;
	InitializeTerrainArray();
	SetNeighbors();
    }
    /// 
    /// Iterates through the terrain object and sets the neightbours to match LOD settings.
    /// 
    private void SetNeighbors()
    {
	ForEachSubterrain ((x, y, subterrain) => {
  		subterrain.SetNeighbors(SafeGetTerrain(x - 1, y),
		                                        SafeGetTerrain(x, y + 1),
		                                        SafeGetTerrain(x + 1, y),
						        SafeGetTerrain(x, y - 1));
	});
    }
#region [ Array Helpers ]
    /// 
    /// Safely retrieves the terrain object from the array.
    /// 
    /// The x coordinate.
    /// The y coordinate.
    private Terrain SafeGetTerrain(int x, int y)
    {
        if (x < 0 || y < 0 || x >= Detail || y >= Detail)
	    return null;
	return subterrains[y, x];
    }
    /// 
    /// Iterates over terrain object and executes the given action
    /// 
    /// Lambda.
    private void ForEachSubterrain(Action lambda) {
        for (int x = 0; x < Detail; x++) {
	    for (int y = 0; y < Detail; y++) {
	        lambda (x, y, SafeGetTerrain(x, y));
	    }
	}
    }
#endregion
}


The actual creation of landscapes takes place in the InitializeTerrainArray () method, which fills the Terrain array with new instances and moves them to the right place in the game world. The BuildNewTerrain () method creates the next instance and initializes it with the necessary parameters and also places the “parent” inside the GameObject'a (it is assumed that a game object will be pre-created on the stage that contains the SuperTerrain chunk so as not to pollute the inspector with unnecessary game objects and simplify cleanup if needed.)

Here, one of the problems with black stripes at the borders of the landscape is also treated - the SetNeighbors () method, which iterates over the created landscapes and puts them to the neighbors. Important: The TerrainData.SetNeighbors () method must be used.for all landscapes in a group. That is, if you indicated that landscape A is a neighbor on top of landscape B, then you also need to indicate that landscape B is a neighbor on the bottom for landscape A. This redundancy is not entirely clear, but it greatly simplifies the iterative application of the method, as in our case.

There are some interesting points in the code above, for example - using divisor to create the next landscape. To be honest, I don’t understand why this is necessary - just creating a landscape in the usual way (without divisor) creates a landscape of the wrong size (which can be a bug, or maybe I just read the documentation badly). This amendment was obtained empirically and still has not failed, so I decided to leave it as it is.

You may also notice that there are two suspicious helper methods at the bottom of the listing. In fact, this is simply the result of refactoring (since I am listing the more or less stable version, which has undergone several refactoring, but is still not perfect). These methods are used further when applying local and global deformations. From their name it’s easy to guess what they are doing.

Applying a global elevation map

Now that the landscape has been created, it's time to teach him how to use the “global elevation map”. For this, SuperTerrain provides

a couple of methods
    /// 
    /// Sets the global heightmap to match the given one. Given heightmap must match the (SubterrainHeightmapResolution * Detail).
    /// 
    /// Heightmap to set the heights from.
    public void SetGlobalHeightmap(float[,] heightmap) {
        ForEachSubterrain((x, y, subterrain) => {
	    var chunkStartX = x * GameplayConstants.SuperTerrainHeightmapResolution;
	    var chunkStartY = y * GameplayConstants.SuperTerrainHeightmapResolution;
            var nextChunkStartX = chunkStartX +  GameplayConstants.SuperTerrainHeightmapResolution + 1;
            var nextChunkStartY = chunkStartY + GameplayConstants.SuperTerrainHeightmapResolution + 1;
            var sumHm = GetSubHeightMap(heightmap, nextChunkStartX, nextChunkStartY, chunkStartX, chunkStartY));
	    subterrain.terrainData.SetHeights(0, 0, subHm);                                                                  
	});
    }
    /// 
    /// Retrieves the minor heightmap from the entire heightmap array.
    /// 
    /// The minor height map.
    /// Major heightmap.
    /// Xborder.
    /// Yborder.
    /// The x coordinate.
    /// The y coordinate.
    private float[,] GetSubHeightMap (float[,] heightMap, int Xborder, int Yborder, int x, int y)
    {
        if (Xborder == x || Yborder == y || x < 0 || y < 0)
	    return null;
	var temp = new float[Yborder - y, Xborder - x];
	    for (int i = x; i < Xborder; i++) {
	        for(int j = y; j < Yborder; j++) {
		    temp[j - y, i - x] = heightMap[j, i];
		}
	    }
         return temp;
    }


I agree, this pair of methods does not look very nice, but I will try to explain everything. So, the name of the SetGlobalHeightmap method speaks for itself. All that he does is iterates over all chunk (which are called subterrain here) and applies to them exactly that piece of the height map that corresponds to its coordinates. Here the ill-fated SetHeights is used, the performance of which forces us to go to all these perversions. As can be seen from the code, the SuperTerrainHeightmapResolution constant does not take into account the difference in 1 resolution of the height map from the power of two (whose existence is justified in the previous section). And let its name not confuse you - this constant stores the resolution of the height map for chunk, and not for the entire SuperTerrain. Since SuperTerrain code makes extensive use of various constants, I'll show you the GameplayConstants class right away. Perhaps it will be more understandable what is happening all the same. I removed everything not related to SuperTerrain from this class.

GameplayConstants.cs
namespace Habitat.Game
{
    /// 
    /// Contains the gameplay constants.
    /// 
    public static class GameplayConstants
    {
	/// 
	/// The height of the world. Used in terrain raycasting and Superterrain generation.
	/// 
	public const float WorldHeight = 512f;
	/// 
	/// Number of the "Terrain" layer
	/// 
	public const int TerrainLayer = 8;
	/// 
	/// Calculated mask for raycasting against the terrain.
	/// 
	public const int TerrainLayerMask = 1 << TerrainLayer;
	/// 
	/// Superterrain part side size.
	/// 
	public const int SubterrainSize = 256;
	/// 
	/// Heightmap resolution for the SuperTerrain.
	/// 
	public const int SuperTerrainHeightmapResolution = 512;
	/// 
	/// Pixel error for the SuperTerrain.
	/// 
        public const int SuperTerrainPixelError = 1;
    }
}


As for the GetSubHeightMap method, this is just another helper, copying part of a part of the transferred matrix to the minor matrix. This is because SetHeights cannot apply part of the matrix. This limitation causes a whole bunch of extra memory allocations, but nothing can be done about it. Unfortunately, Unity developers did not provide a real-time landscape change scenario.

The GetSubHeightMap method is used further when applying local deformations, but more on that later.

Local strain application

To apply deformations, you need not only a height map, but also other information such as coordinates, method of application, dimensions, etc. In this version, all information is encapsulated in the TerrainDeformation class, a listing of which can be seen

here.
namespace Habitat.DynamicTerrain.Deformation {
    public abstract class TerrainDeformation
    {
	/// 
	/// Height of the deformation in hightmap pixels.
	/// 
        public int H { get; private set; }
        /// 
	/// Width of the deformation in hightmap pixels.
	/// 
        public int W { get; private set; }
        /// 
	/// Heightmap matrix object
	/// 
        public float[,] Heightmap { get; private set; }
        /// 
	/// Initializes a new instance of the  class.
	/// 
	/// Height in heightmap pixels
	/// Width in heightmap pixels
        protected TerrainDeformation(int height, int width)
        {
            H = height;
            W = width;
	    Heightmap = new float[height,width];
        }
	/// 
	/// Initializes a new instance of the  class.
	/// 
	/// Normalized heightmap matrix.
        protected TerrainDeformation(float[,] bitmap)
        {
            Heightmap = bitmap;
            H = bitmap.GetUpperBound(0);
            W = bitmap.GetUpperBound(1);
        }
	/// 
	/// Applies deformation to the point. Additive by default.
	/// 
	/// The to point.
	/// Current value.
	/// New value.
	public virtual float ApplyToPoint(float currentValue, float newValue) {
	    return currentValue + newValue;
	}
	/// 
	/// Generates the heightmap matrix based on constructor parameters.
	/// 
	public abstract TerrainDeformation Generate();
    }
}


It is easy to guess that the heirs of this class implement the abstract Generate () method, where they describe the logic for creating the appropriate heightmap for deformation. TerrainDeformation also contains information about how it should be applied to the current landscape - this is determined by the virtual ApplyToPoint method. By default, it defines the deformation as additive, but by overloading the method, more complex methods of combining two heights can be achieved. As for the division of the deformation matrix into sub-matrices and their application to the corresponding chunk, this code is in the SuperTerrain class and highlighted in

the following group of methods:
/// 
/// Compound terrain object.
/// 
public class SuperTerrain 
{
    //...
    ///
    ///Resolution of each terrain in the SuperTerrain;
    ///
    private readonly int hmResolution = GameplayConstants.SuperTerrainHeightmapResolution;
    /// Applies the partial heightmap to a single terrain object.
    /// 
    /// Heightmap.
    /// Terrain x.
    /// Terrain y.
    /// Start x.
    /// Start y.
    /// Deformation type.
    private void ApplyPartialHeightmap(float[,] heightmap, int chunkX, int chunkY,
                                                            int startX, int startY, TerrainDeformation td)
    {
	if (heightmap == null)
            return;
	var current = subterrains [chunkY, chunkX].terrainData.GetHeights(
            startX,
            startY,
            heightmap.GetUpperBound (1) + 1,
            heightmap.GetUpperBound (0) + 1); 
        for (int x = 0; x <= heightmap.GetUpperBound(1); x++) {
            for (int y = 0; y <= heightmap.GetUpperBound(0); y++) {
		current[y, x] = td.ApplyToPoint(current[y, x], heightmap[y, x]);
	    }
	}
        subterrains[chunkY, chunkX].terrainData.SetHeights (startX, startY, current);
    }
    private int TransformCoordinate (float coordinate)
    {
        return Mathf.RoundToInt(coordinate * hmResolution / GameplayConstants.SubterrainSize);
    }
    /// 
    /// Applies the local deformation.
    /// 
    /// Deformation.
    /// The x coordinate.
    /// The y coordinate.
    public void ApplyDeformation(TerrainDeformation td, float xCoord, float yCoord)
    {
        int x = TransformCoordinate (xCoord);
	int y = TransformCoordinate (yCoord);
	var chunkX = x / hmResolution;
	var chunkY = y / hmResolution;
        ApplyPartialHeightmap(GetBottomLeftSubmap(td, x, y), chunkX - 1, chunkY - 1, hmResolution, hmResolution, td);
	ApplyPartialHeightmap(GetLeftSubmap(td, x, y), chunkX - 1, chunkY, hmResolution, y % hmResolution, td);
	ApplyPartialHeightmap(GetTopLeftSubmap(td, x, y), chunkX - 1, chunkY + 1, hmResolution, 0, td);
	ApplyPartialHeightmap(GetBottomSubmap(td, x, y), chunkX, chunkY - 1, x % hmResolution, hmResolution, td);
	ApplyPartialHeightmap(GetBottomRightSubmap(td, x, y), chunkX + 1, chunkY - 1, 0, hmResolution, td);
	ApplyPartialHeightmap(GetMiddleSubmap(td, x, y), chunkX, chunkY, x % hmResolution, y % hmResolution, td);
	ApplyPartialHeightmap(GetTopSubmap(td, x, y), chunkX, chunkY + 1, x % hmResolution, 0, td);
	ApplyPartialHeightmap(GetRightSubmap(td, x, y), chunkX + 1, chunkY, 0, y % hmResolution, td);   
	ApplyPartialHeightmap(GetTopRightSubmap(td, x, y), chunkX + 1, chunkY + 1, 0, 0, td);            
    }
    ///Retrieves the bottom-left part of the deformation (Subheightmap, applied to the bottom
    ///left chunk of the targetChunk) or null if no such submap has to be applied.
    ///Covers corner cases
    private float[,] GetBottomLeftSubmap(TerrainDeformation td, int x, int y) {
       if (x % hmResolution == 0 && y % hmResolution == 0 && x / hmResolution > 0 && y / hmResolution > 0)
       {
           return new float[,] {{ td.Heightmap[0, 0] }};
       }
       return null;
    }
    ///Retrieves the left part of the deformation (Subheightmap, applied to the
    ///left chunk of the targetChunk) or null if no such submap has to be applied.
    ///Covers edge cases        
    private float[,] GetLeftSubmap(TerrainDeformation td, int x, int y) {
        if (x % hmResolution == 0 && x / hmResolution > 0)
        {
            int endY = Math.Min((y / hmResolution + 1) * hmResolution, y + td.H);
            return GetSubHeightMap(td.Heightmap, 1, endY - y, 0, 0);
        }
        return null;
    }
    ///Retrieves the bottom part of the deformation (Subheightmap, applied to the bottom
    ///chunk of the targetChunk) or null if no such submap has to be applied.
    ///Covers edge cases
    private float[,] GetBottomSubmap(TerrainDeformation td, int x, int y) {
        if (y % hmResolution == 0 && y / hmResolution > 0)
        {
            int endX = Math.Min((x / hmResolution + 1) * hmResolution, x + td.W);
            return GetSubHeightMap(td.Heightmap, endX - x, 1, 0, 0);
        }
        return null;
    }
    ///Retrieves the top-left part of the deformation (Subheightmap, applied to the top
    ///left chunk of the targetChunk) or null if no such submap has to be applied.
    ///Covers split edge cases
    private float[,] GetTopLeftSubmap(TerrainDeformation td, int x, int y) {
        if (x % hmResolution == 0 && x / hmResolution > 0)
        {
            int startY = (y / hmResolution + 1) * hmResolution;
            int endY = y + td.H;
            if (startY > endY) return null;
            return GetSubHeightMap(td.Heightmap, 1, td.H, 0, startY - y);
        }
        return null;
    }
    ///Retrieves the bottom-right part of the deformation (Subheightmap, applied to the bottom
    ///right chunk of the targetChunk) or null if no such submap has to be applied.
    ///Covers split edge cases
    private float[,] GetBottomRightSubmap(TerrainDeformation td, int x, int y) {
        if (y % hmResolution == 0 && y / hmResolution > 0)
        {
            int startX = (x / hmResolution + 1) * hmResolution;
            int endX = x + td.W;
            if (startX > endX) return null;
            return GetSubHeightMap(td.Heightmap, td.W, 1, startX - x, 0);
        }
        return null;
    }
    ///Retrieves the main deformation part.
    private float[,] GetMiddleSubmap(TerrainDeformation td, int x, int y) {
        int endX = Math.Min((x / hmResolution + 1) * hmResolution, x + td.W);
        int endY = Math.Min((y / hmResolution + 1) * hmResolution, y + td.H);
        return GetSubHeightMap(td.Heightmap,
                      Math.Min(endX - x + 1, td.Heightmap.GetUpperBound(0) + 1),
                      Math.Min(endY - y + 1, td.Heightmap.GetUpperBound(1) + 1),
                      0, 0);
    }
    ///Retrieves the top deformation part or null if none required
    private float[,] GetTopSubmap(TerrainDeformation td, int x, int y) {
        int startY = (y / hmResolution + 1) * hmResolution;
        if (y + td.H < startY) return null;
        int endX = Math.Min((x / hmResolution + 1) * hmResolution, x + td.W);  
        return GetSubHeightMap(td.Heightmap, Math.Min (endX - x + 1, td.Heightmap.GetUpperBound(0) + 1), td.H, 0, startY - y); 
    }
    ///Retrieves the left deformation part or null if none required
    private float[,] GetRightSubmap(TerrainDeformation td, int x, int y) {
        int startX = (x / hmResolution + 1) * hmResolution;
        if (x + td.W < startX) return null;
        int endY = Math.Min((y / hmResolution + 1) * hmResolution, y + td.H);
        return GetSubHeightMap(td.Heightmap, td.W, Math.Min(endY - y + 1, td.Heightmap.GetUpperBound(1) + 1), startX - x, 0);
    }
    ///Retrieves the top-right part of the main deformation.
    private float[,] GetTopRightSubmap(TerrainDeformation td, int x, int y) {
        int startX = (x / hmResolution + 1) * hmResolution;
        int startY = (y / hmResolution + 1) * hmResolution;
        if (x + td.W < startX || y + td.H < startY) return null;
        return GetSubHeightMap(td.Heightmap, td.W, td.H, startX - x, startY - y);
    }
}


As you probably already guessed, the only public method that is in the listing is the most important one. The ApplyDeformation () method allows you to apply the specified deformation to the terrain in the specified coordinates. First of all, when it is called, the coordinates on the landscape are converted to the coordinates on the height map (remember? If the landscape dimensions differ from the resolution of the height map, this must be taken into account). All work on applying deformation takes place inside nine ApplyPartialHeightmap calls that apply chunks of the height map from deformation to their corresponding chunk. As I said earlier, we need exactly nine parts, not four to take into account all possible boundary and angular cases:



It is this division that GetXXXSubmap () methods are involved in - obtaining the necessary deformation minors based on data on the deformation position and the boundaries of various chunk's. Each of the methods returns null if the deformation has no effect on the corresponding chunk and the method for applying these minors (ApplyPartialHeightmap ()) does nothing if it receives null at the input.

Results and Conclusions


The resulting mechanism, of course, is far from ideal, but it is already functional and allows you to adjust important terrain parameters in order to achieve some flexibility in terms of performance settings. Among the major potential improvements are the following:

  • The hard work is done in a separate process to reduce the effect on framerate in particularly intense scenes.
  • Optimize the minorization by getting rid of memory allocation each time, for example, through caching. So far, it’s hard to imagine how it would be possible to cache something like that. For starters, you can limit yourself to the most frequent cases - a slight deformation right in the middle of the chunk.
  • Add the ability to influence not only the landscape geometry, but also its texture - deformations with changing splat-maps.
  • Optimizations for applying multiple settings in a single frame. For example, to accumulate deformations for chunk in some buffer and at the end of processing the logic in some way to combine and apply them - we get one call to SetHeights on the chunk, even if there were several deformations.

Screenshots
Small, barely noticeable deformations due to multiple hits of shells:



Dynamically created fault in the ground (Parameters were not very well chosen, as a result, “battlements”.



One of the chunk's was highlighted, deformations at the joints are applied correctly, there are no visible gaps:



A large crater occupying the entire chunk.




And, of course, links to playable demos:

For Windows

For Linux

Some instructions
This is not a demo of deformations specifically, but rather a small RTS that I do. It has the ability to deform the landscape and this deformation affects the gameplay. The only thing that affects the landscape in the game is the shooting of tanks. However, by pressing the "~" key in the game, you can open the development console. By writing “man” or “help”, you can see a list of available commands, including spawn_crater and sv_spawn_animdef. They can be used to apply a crater / animated warp as in a video. I will be very grateful if you can run the demo on your machine and produce some benchmark'i and leave the results (your framerate during the animated deformation, your feelings in general) as a comment on the archive with the demo (on google drive).

Management in the Demo: Mouse + WASD = move the camera. Mouse Wheel = Zoom. Ctrl = rotate the camera.

Also popular now: