Pull to refresh

Создание динамически изменяемого ландшафта для RTS на Unity3D

Reading time 21 min
Views 34K
Давным-давно я имел радость играть в замечательнейшую RTS под названием «Периметр: Геометрия Воины» от отечественного разработчика K-D Labs. Это игра о том, как огромные летающие города под названием «Фреймы» бороздят просторы «Спанджа» — цепи соединенных между собой миров. Сюжет достаточно странный и абстрактный, но гораздо более интересной и инновационной составляющей игры была одна из ее технических особенностей, а не сюжет. В отличие от большинства RTS, где сражения происходят на статической местности, в «Периметре», одной из ключевых игровых механик был терраформинг. У игрока были средства манипулировать ландшафтом с целью возведения на нем своих сооружений а также целый арсенал боевых единиц, способных этот ландшафт превратить в потрескавшийся, поплывший и изрыгающий раскаленные камни/противных насекомых ужас.

Как известно, мир RTS нынче переживает некоторый упадок. Инди-разработчики слишком заняты тем, что клепают ретро-платформеры и rouge-like игры зубодробительной сложности, и поэтому, переиграв в «Периметр» некоторое время назад я решил, что должен и сам попробовать реализовать что-то подобное — идея была интересной и с технической и с геймплейной точек зрения. Обладая некоторым практическим опытом в разработке игр (ранее я совершал попытки сделать кое-что на XNA), я подумал, что чтобы добиться хоть какого-то успеха в одиночку мне придется воспользоваться чем нибудь более высокоуровневым и простым. Выбор мой пал на Unity 3D, чья пятая версия только-только вышла из под пресса.

Вооруженный вагоном энтузиазма, вдохновением от только что пройденного «Периметра» и просмотренной серией видеотуториалов по Unity, я начал делать наброски и знакомиться с инструментарием, который мне предложил Unity Editor.

Что предлагает community


Как всегда, первый мой блин вышел комом. Без достаточного обдумывания я начал реализовывать ландшафт с помощью плоскости и кода, который должен был поднимать либо опускать вершины этой плоскости. Многие читатели, хотя бы немного знакомые с Unity могут возразить «Но ведь у Unity есть компонент Terrain, предназначенный специально для этих целей!» — и будут правы. Проблема лишь в том, что я, будучи слишком увлеченным реализацией своей идеи забыл об одной важной вещи: RTFM! Изучи я документацию и форумы чуть более тщательно, я бы не стал решать задачу таким откровенно дурацким способом, а сразу воспользовался бы готовым компонентом.

После двух дней бесполезного пота и алгоритмистики (Плоскость явно не была предназначена для использования в таких целях), я таки начал делать ландшафт с использованием Terrain. Должен сказать, что среди отдельных членов сообщества Unity, ходила идея создания динамического ландшафта для своей игры. Некоторые люди задавали на форумах вопросы и получали ответы. Им рекомендовали использовать метод SetHeights, принимающий на вход кусок нормализованного от 0f до 1f heightmap'а, который будет установлен начиная с точки (xBase; yBase) на выбранный ландшафт.

Довольный результатами своих поисков, я начал разработку. Первый рабочий прототип был готов пару часов спустя и включал в себя простейшую двигалку камеры (с использованием правой клавиши), простой генератор кратеров и собственно добавление этих кратеров на ландшафт по нажатию на левую клавишу (билд можно схватить и посмотреть здесь).

Сама по себе деформирующая часть была до неприличия простая.

Это был скрипт примерно следующего содержания
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);
}



Объект DeformationData содержал в себе координаты X и Y, к которым нужно применить деформацию, нормализованный heightmap, который аддитивно либо мультипликативно накладывался на текущий ландшафт и прочий boilerplate, необходимый для работы механизма деформаций.

Также был генератор деформаций, позволяющий, например,

сгенерировать кратер по заданным параметрам
// Методы являются частью класса  - переменные 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;
        }
    }
}



И все это было основой всего Tech Demo, если так конечно можно выразиться.

Анализ результатов первой попытки


Если вы посмотрели Tech Demo, то, вероятно, сразу же заметили определенные проблемы в механизме деформаций. Если же вы не смотрели его (за что я вас не виню), то я вам расскажу что было не так. Основной проблемой была производительность. Точнее ее полное отсутствие. Когда начинались деформации, framerate падал до очень маленьких (на некоторых машинах однозначных) чисел, что было неприемлемо, потому как никакой графики в игре по сути не было. Мне удалось выяснить, что сам по себе метод SetHeights() вызывает сложнейшую череду расчетов LOD для ландшафта и по этой причине не годится для деформаций ландшафта в реальном времени. Казалось бы, мои надежды рухнули и реализация деформаций в реальном времени на Unity невозможна, но я не сдавался и выяснил очевидную, но очень важную особенность механизма пересчета LOD.

Чем меньше разрешение карты высот ландшафта, тем меньше удар по производительности при применении SetHeights().

Разрешение карты высот — это параметр, характеризующий качество отображения ландшафта. Оно является целочисленным (очевидно), отчего в сниппетах выше для обозначения координаты на карте использовались целочисленные переменные. И оно может быть больше размеров ландшафта, например, для ландшафта 256х256 можно установить разрешение карты высот равным 513, что придаст ландшафту точности и менее угловатые очертания. Почему именно 513, а не 512, я расскажу в следующей секции.

Игры с разрешением карты высот позволили мне найти более-менее оптимальные размеры для моей конфигурации, но я был сильно огорчен результатами. Для успешного применения такого ландшафта в RTS, его размеры должны быть достаточно большими, чтобы на нем можно было сосуществовать некоторое время хотя бы двум игрокам. По моим первоначальным оценкам, карта размером 2х2км (или 2048х2048 Unity Units) должна была быть в самый раз. Для того, чтобы не замечать влияния на framerate деформаций в реальном времени, размер ландшафта должен был быть не более 512х512 единиц. Более того, одинарная точность карты высот давала не самые впечатляющие результаты, когда дело касалось визуального качества. Ландшафт был местами угловат и кривоват, что требовало удвоения точности карт высот.

В общем, дела обстояли не очень хорошо.

Super Terrain — концепт и теория


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

Примерно тогда меня начала посещать следующая мысль: «Раз мы не можем сделать один большой ландшафт и при этом иметь достаточную производительность при деформациях, то почему бы не сделать кучу маленьких и не разместить их бок о бок? — Как chunk'и в Minecraft?» Идея была неплохой, но была сопряжена с некоторыми проблемами:

  • Как выбрать размер для «chunk'а»
  • Как сделать так, чтобы на стыках «chunk'ов» не было заметных швов
  • Как применять деформации, происходящие на стыках соседних chunk'ов

Первая проблема

Первая проблема была достаточно пустяковой: Я просто выбрал размер для chunk'а 256х256 с двойной точностью (heightmap resolution = 513). Такой setup не вызывал у проблем с производительностью на моей машине. Возможно в будущем пришлось бы пересмотреть размеры chunk'ов, но на текущем этапе, такое решение меня устраивало.

Вторая проблема

Что касается второй проблемы, то у нее было две составляющих. Первая, очевидно, состояла в том, чтобы выравнять высоты соседних «пикселей» карт высот соседних chunk'ов. Именно во время решения этой проблемы я и понял, почему разрешение карты высот является степенью двойки + 1. Продемонстрирую на иллюстрации:



Очевидно, что для того, чтобы сохранялось равенство высот в соседних ландшафтах, «последний» пиксель карты высот первого ландшафта должен быть по высоте равен «первому» пикселю следующего:



Очевидно, «Super Terrain» — это и есть матрица Unity Terrain'ов, объединенных механизмом наложения heightmap'ов и деформаций.

После того, как реализация кода для объединения ландшафтов была завершена (применение локальных деформаций малого размера было оставлено на потом — сейчас требовалось разработать механизм создания матрицы Terrain'ов и начальная инициализация карты высот), открылась еще одна составляющая второй проблемы (отсутствие швов на стыке ландшафтов), которая к счастью была разрешена достаточно просто. Проблема состояла в том, что для такого «совмещения» ландшафтов необходимо объяснить Unity, что они используются совместно и являются соседними. Для этого разработчиками был предусмотрен метод SetHeighbors. Мне до сих пор не совсем понятно, как он работает, но без него на стыках ландшафтов появляются артефакты с тенями и темные полосы.

Третья проблема

Самая интересная и сложная из трех, эта проблема не давала мне покоя не меньше недели. Я отбросил четыре различных реализации пока не пришел к финальной, о которой я и расскажу. Сразу сделаю небольшое замечание об одном важном ограничении моей реализации — она предполагает, что локальная деформация не может быть размером больше, чем один chunk. Деформация по прежнему может находиться на их стыке, однако сторона матрицы деформации не должна превышать разрешения карты высот chunk'а и все они должны быть квадратными (карты высот, сами chunk'и и деформации). В целом, это не является большим ограничением, так как любая деформация больших размеров может быть получена путем применения нескольких малых по очереди. Что касается «квадратности», то это ограничение карты высот. То есть обязана быть квадратной только ее карта высот, на которой могут быть «нулевые» участки при аддитивном либо «единичные» при мультипликативном применениях.

Сама по себе идея алгоритма для универсального применения деформации состояла в следующем:

  1. Разделить heightmap деформации на девять частей по одной на каждый из chunk'ов, на который потенциально может повлиять деформация. Так центральная часть будет отвечать за деформацию chunk'а, на который непосредственно попала деформация, стороны отвечают за chunk'и, находящиеся слева/справа или сверху/снизу и т.д. В случае, если деформация не изменяет chunk, то ее составляющая будет равна null.
  2. Применить частичные heightmap'ы к соответствующим chunk'ам, либо проигнорировать изменения, если частичный heightmap равен null.

Такой подход позволяет универсально применять и деформации находящиеся как в самом центре chunk'а и не затрагивающие других chunk'ов, так и на границах с ними либо даже в углах. Делить именно на девять частей (а не на четыре) нужно по причине того, что в случае, если деформация начинается или заканчивается на границе chunk'а, граничные пиксели того, который находится по соседству с ним тоже должны быть изменены. (Для того, чтобы не было видимым швов — см. решение проблемы номер 2).

Super Terrain — практика


Создание SuperTerrain

В рамках второй проблемы был разработан мезанизм, позволяющий объединить несколько Terrain'ов в один и применять «глобальные» карты высот, ложащиеся на весь ландшафт целиком.

Скажу сразу, что понадобилась эта возможность глобального применения карты высот потому как для создание ландшафта было процедурным, а для его использования использовался Square-Diamond алгоритм, выходом которого и была большая матрица float'ов — наша большая карта высот.

В целом, создание SuperTerrain — достаточно простой и интуитивно понятный процесс, описанный

здесь
/// <summary>
/// Compound terrain object.
/// </summary>
public class SuperTerrain 
{
    /// <summary>
    /// Contains the array of subterrain objects
    /// </summary>
    private Terrain[,] subterrains;
		
    /// <summary>
    /// Superterrain detail. The resulting superterrain is 2^detail terrains.
    /// </summary>
    /// <value>The detail.</value>
    public int Detail { get; private set; }
		
    /// <summary>
    /// Parent gameobject to nest created terrains into.
    /// </summary>
    /// <value>The parent.</value>
    public Transform Parent { get; private set; }
		
    /// <summary>
    /// Builds the new terrain object.
    /// </summary>
    /// <returns>The new terrain.</returns>
    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<Terrain>();
			
        newTerrain.transform.parent = Parent;
        newTerrain.transform.gameObject.layer = GameplayConstants.TerrainLayer;
        newTerrain.heightmapPixelError = GameplayConstants.SuperTerrainPixelError;
			
        return newTerrain;
    }
		
    /// <summary>
    /// Initializes the terrain array and moves the terrain transforms to match their position in the array.
    /// </summary>
    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));
	    }
	}
    }
		
    /// <summary>
    /// Initializes a new instance of the <see cref="SuperTerrain"/> class.
    /// </summary>
    /// <param name="detail">Superterrain detail. The resultsing superterrain is 2^detail terrains.</param>
    /// <param name="parent">Parent gameobject to nest created terrains into.</param>
    public SuperTerrain(int detail, Transform parent)
    {
        Detail = detail;
	Parent = parent;
			
	InitializeTerrainArray();
	SetNeighbors();
    }

    /// <summary>
    /// Iterates through the terrain object and sets the neightbours to match LOD settings.
    /// </summary>
    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 ]
    /// <summary>
    /// Safely retrieves the terrain object from the array.
    /// </summary>
    /// <param name="x">The x coordinate.</param>
    /// <param name="y">The y coordinate.</param>
    private Terrain SafeGetTerrain(int x, int y)
    {
        if (x < 0 || y < 0 || x >= Detail || y >= Detail)
	    return null;
	return subterrains[y, x];
    }

    /// <summary>
    /// Iterates over terrain object and executes the given action
    /// </summary>
    /// <param name="lambda">Lambda.</param>
    private void ForEachSubterrain(Action<int, int, Terrain> lambda) {
        for (int x = 0; x < Detail; x++) {
	    for (int y = 0; y < Detail; y++) {
	        lambda (x, y, SafeGetTerrain(x, y));
	    }
	}
    }

#endregion
}


Собственно создание ландшафтов происходит в методе InitializeTerrainArray(), который заполняет массив Terrain'ов новыми экземплярами и перемещает их на нужное место в игровом мире. Метод BuildNewTerrain() создает очередной экземпляр и инициализирует его нужными параметрами а также помещает внутрь GameObject'a «parent» (предполагается, что на сцене будет предварительно создан игровой объект, вмещающий в себя chunk'и SuperTerrain'а, чтобы не загрязнять инспектор лишними игровыми объектами и упростить cleanup, если он понадобится.)

Здесь же применяется лечение одной из проблем с черными полосами на границах ландшафта — метод SetNeighbours(), который итерируется по созданным ландшафтам и проставляет им соседей. Важное замечание: метод TerrainData.SetNeighbors() должен применяться для всех ландшафтов в группе. То есть, если вы указали, что ландшафт А является соседом сверху ландшафта В, то вам также нужно указать что ландшафт В является соседом снизу для ландшафта А. Эта избыточность не совсем понятна, однако она значительно упрощает итеративное применение метода, как в нашем случае.

В коде выше есть несколько интересных моментов, например — использование divisor'а при создании очередного ландшафта. Если честно, я и сам не понимаю, почему это нужно — просто создание ландшафта обычным способом (без divisor'а) создает ландшафт неправильного размера (что может быть багом, а может я просто плохо читал документацию). Данная поправка была получена эмпирически и до сих пор не подводила, поэтому я решил оставить ее как есть.

Вы также могли заметить, что внизу листинга имеются два подозрительных метода-хэлпера. На самом деле, это просто результат рефакторинга (так как я показываю листинги более-менее стабильной версии, которая прошла несколько рефакторингов, но все еще не идеальна). Эти методы используются и дальше, при применении локальных и глобальных деформаций. Из их названия несложно догадаться, что они делают.

Применение глобальной карты высот

Теперь, после того как ландшафт создан, настало самое время научить его применять «глобальную карту высот». Для этого в SuperTerrain предусмотрена

пара методов
    /// <summary>
    /// Sets the global heightmap to match the given one. Given heightmap must match the (SubterrainHeightmapResolution * Detail).
    /// </summary>
    /// <param name="heightmap">Heightmap to set the heights from.</param>
    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);                                                                  
	});
    }
		
    /// <summary>
    /// Retrieves the minor heightmap from the entire heightmap array.
    /// </summary>
    /// <returns>The minor height map.</returns>
    /// <param name="heightMap">Major heightmap.</param>
    /// <param name="Xborder">Xborder.</param>
    /// <param name="Yborder">Yborder.</param>
    /// <param name="x">The x coordinate.</param>
    /// <param name="y">The y coordinate.</param>
    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;
    }


Согласен, выглядит эта пара методов не очень красиво, но я постараюсь все объяснить. Итак, название метода SetGlobalHeightmap говорит само за себя. Все, что он делает — итерируется по всем chunk'ам (которые здесь названы subterrain'ами) и применяет к ним именно тот кусочек карты высот, который соответствует его координатам. Здесь и используется злополучный SetHeights, производительность которого и заставляет нас идти на все эти извращения. Как видно из кода, константа SuperTerrainHeightmapResolution не учитывает отличие на 1 разрешения карты высот от степени двойки (чье существование обосновывается в прошлой секции). И пусть вас не смутит ее название — эта константа хранит разрешение карты высот для chunk'а, а не для всего SuperTerrain. Поскольку код SuperTerrain активно использует различные константы, я сразу покажу вам класс GameplayConstants. Возможно, так будет понятнее, что же все таки происходит. Я убрал из данного класса все не относящееся к SuperTerrain.

GameplayConstants.cs
namespace Habitat.Game
{
    /// <summary>
    /// Contains the gameplay constants.
    /// </summary>
    public static class GameplayConstants
    {
	/// <summary>
	/// The height of the world. Used in terrain raycasting and Superterrain generation.
	/// </summary>
	public const float WorldHeight = 512f;

	/// <summary>
	/// Number of the "Terrain" layer
	/// </summary>
	public const int TerrainLayer = 8;

	/// <summary>
	/// Calculated mask for raycasting against the terrain.
	/// </summary>
	public const int TerrainLayerMask = 1 << TerrainLayer;

	/// <summary>
	/// Superterrain part side size.
	/// </summary>
	public const int SubterrainSize = 256;

	/// <summary>
	/// Heightmap resolution for the SuperTerrain.
	/// </summary>
	public const int SuperTerrainHeightmapResolution = 512;

	/// <summary>
	/// Pixel error for the SuperTerrain.
	/// </summary>
        public const int SuperTerrainPixelError = 1;
    }
}


Что касается метода GetSubHeightMap, то это просто очередной хэлпер, копирующий часть часть переданной матрицы в матрицу-минор. Это нужно потому, что SetHeights не может применить часть матрицы. Это ограничение вызывает целую кучу лишних выделений памяти, но с ним ничего нельзя поделать. К сожалению, разработчики Unity не предусмотрели сценарий изменения ландшафта в реальном времени.

Метод GetSubHeightMap используется и дальше при применении локальных деформаций, но об этом позже.

Применение локальных деформаций

Для применения деформаций нужна не только карта высот, но и прочая информация типа координат, способа применения, размеров и т.д. В данной версии вся информация инкапсулирована в класс TerrainDeformation, листинг которого можно увидеть

здесь.
namespace Habitat.DynamicTerrain.Deformation {
    public abstract class TerrainDeformation
    {
	/// <summary>
	/// Height of the deformation in hightmap pixels.
	/// </summary>
        public int H { get; private set; }
	
        /// <summary>
	/// Width of the deformation in hightmap pixels.
	/// </summary>
        public int W { get; private set; }
	
        /// <summary>
	/// Heightmap matrix object
	/// </summary>
        public float[,] Heightmap { get; private set; }
	
        /// <summary>
	/// Initializes a new instance of the <see cref="Habitat.DynamicTerrain.Deformation.TerrainDeformation"/> class.
	/// </summary>
	/// <param name="height">Height in heightmap pixels</param>
	/// <param name="width">Width in heightmap pixels</param>
        protected TerrainDeformation(int height, int width)
        {
            H = height;
            W = width;
	    Heightmap = new float[height,width];
        }

	/// <summary>
	/// Initializes a new instance of the <see cref="Habitat.DynamicTerrain.Deformation.TerrainDeformation"/> class.
	/// </summary>
	/// <param name="bitmap">Normalized heightmap matrix.</param>
        protected TerrainDeformation(float[,] bitmap)
        {
            Heightmap = bitmap;
            H = bitmap.GetUpperBound(0);
            W = bitmap.GetUpperBound(1);
        }

	/// <summary>
	/// Applies deformation to the point. Additive by default.
	/// </summary>
	/// <returns>The to point.</returns>
	/// <param name="currentValue">Current value.</param>
	/// <param name="newValue">New value.</param>
	public virtual float ApplyToPoint(float currentValue, float newValue) {
	    return currentValue + newValue;
	}

	/// <summary>
	/// Generates the heightmap matrix based on constructor parameters.
	/// </summary>
	public abstract TerrainDeformation Generate();
    }
}


Несложно догадаться, что наследники этого класса реализуют абстрактный метод Generate(), где и описывают логику по созданию соответствующего heightmap'а для деформации. Также TerrainDeformation содержит информацию о том, как именно она должна применяться к текущему ландшафту — это определяет виртуальный метод ApplyToPoint. По-умолчанию он определяет деформацию как аддитивную, но перегрузив метод можно добиться более сложных методов комбинирования двух высот. Что касается разделения матрицы деформации на суб-матрицы и применение их к соответствующим chunk'ам, то этот код находится в классе SuperTerrain и выделен в

следующую группу методов:
/// <summary>
/// Compound terrain object.
/// </summary>
public class SuperTerrain 
{
   
    //...

    ///<summary>
    ///Resolution of each terrain in the SuperTerrain;
    ///</summary>
    private readonly int hmResolution = GameplayConstants.SuperTerrainHeightmapResolution;

    /// Applies the partial heightmap to a single terrain object.
    /// </summary>
    /// <param name="heightmap">Heightmap.</param>
    /// <param name="chunkX">Terrain x.</param>
    /// <param name="chunkY">Terrain y.</param>
    /// <param name="startX">Start x.</param>
    /// <param name="startY">Start y.</param>
    /// <param name="type">Deformation type.</param>
    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);
    }

    /// <summary>
    /// Applies the local deformation.
    /// </summary>
    /// <param name="deformation">Deformation.</param>
    /// <param name="x">The x coordinate.</param>
    /// <param name="y">The y coordinate.</param>
    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);
    }
}


Как вы уже наверное догадались, единственный public метод, который есть в листинге — и есть самый главный. Метод ApplyDeformation() позволяет применить указанную деформацию к ландшафту в заданных координатах. Первым делом при его вызове происходит конвертация координат на ландшафте в координаты на карте высот (помните? Если размеры ландшафта отличаются от разрешения карты высот, то это нужно учесть). Вся работа по применению деформации происходит внутри девяти вызовов ApplyPartialHeightmap, которые применяют куски карты высот от деформации к соответствующим им chunk'ам. Как я уже говорил ранее, нам нужно именно девять частей, а не четыре чтобы учесть все возможные граничные и угловые случаи:



Именно этим делением и занимаются методы GetXXXSubmap() — получением необходимых миноров деформации на основании данных о положении деформации и границах различных chunk'ов. Каждый из методов возвращает null, в случае если деформация не имеет влияния на соответствующий chunk и метод по применению этих самых миноров (ApplyPartialHeightmap()) ничего не делает, если на вход ему приходит null.

Результаты и выводы


Получившийся механизм конечно далек от идеала, но он уже функционален и позволяет регулировать важные параметры ландшафта, чтобы добиться кое-какой гибкости в плане настроек производительности. Среди основных потенциальных улучшений можно отметить следующие:

  • Тяжелую работу выполнять в отдельном процессе, чтобы снизить влияние на framerate в особенно интенсивных сценах.
  • Оптимизировать разбиение на миноры, избавившись от выделения памяти каждый раз, посредством, например, кеширования. Пока-что сложно представить как можно будет кэшировать что-то подобное. Для начала можно ограничиться самыми частыми кейсами — небольшая деформация прямо по середине chunk'а.
  • Добавить возможность влиять не только на геометрию ландшафта, но и на его текстуру — деформации с изменением splat-map'ов.
  • Оптимизации для применения нескольких дфеормаций за один кадр. Например, накапливать деформации для chunk'ов в каком-нибудь буфере и по окончанию обработки логики каким-либо образом их комбинировать и применять — получим один вызов SetHeights на chunk, даже если было несколько деформаций.

Скриншоты
Маленькие, едва заметные деформации из-за множественных попаданий снарядов:



Динамически созданный разлом в земле (Параметры были подобраны не очень хорошо, как следствие — «зубчатые» стенки.



Один из chunk'ов выделен, деформации на стыках применяются верно, видимых разрывов нет:



Большой кратер, занимающий весь chunk.




И, разумеется, ссылки на играбельные демо:

Для Windows

Для Linux

Немного инструкций
Это не демо конкретно деформаций, сколько небольшой RTS, которую я делаю. В ней присутствует возможность деформировать ландшафт и эта деформация влияет на геймплей. Единственная вещь, которая влияет на ландшафт в игре — это стрельба танков. Однако, нажав на клавишу "~" в игре можно открыть development console. Написав «man» или «help», можно увидеть список доступных команд, среди которых будут spawn_crater и sv_spawn_animdef. Их можно использовать чтобы применить кратер/анимированную деформацию как в видео. Я буду очень благодарен вам, если вы сможете запустить демо на своей машине и произвести некоторые benchmark'и и оставить результаты (ваш framerate во время анимированной деформации, ваши ощущения в целом) в качестве комментария к архиву с демо (на google drive).

Управление в Демо: Мышь + WASD = перемещение камеры. Колесико мыши = зум. Ctrl = поворачивать камеру.
Tags:
Hubs:
+20
Comments 5
Comments Comments 5

Articles