[Перевод] Карты из шестиугольников в Unity: круговорот воды, эрозия, биомы, цилиндрическая карта

Части 1–3: сетка, цвета и высоты ячеек

Части 4–7: неровности, реки и дороги

Части 8–11: вода, объекты рельефа и крепостные стены

Части 12–15: сохранение и загрузка, текстуры, расстояния

Части 16–19: поиск пути, отряды игрока, анимации

Части 20–23: туман войны, исследование карты, процедурная генерация


  • Добавляем границу из воды вокруг карты.
  • Разделяем карту на несколько регионов.
  • Применяем эрозию, чтобы срезать обрывы.
  • Перемещаем сушу, чтобы сгладить рельеф.


В предыдущей части мы заложили основы процедурной генерации карт. На этот раз мы ограничим места возможного появления суши и воздействуем на неё эрозией.

Этот туториал создан в Unity 2017.1.0.

5ac6a530cef3718f9031abe862364093.jpg


Разделяем и сглаживаем сушу.

Граница карты


Так как мы поднимаем участки суши случайным образом, может случиться так, что суша коснётся края карты. Это может быть нежелательно. Карта, ограниченная водой, содержит естественный барьер, не позволяющий игрокам приближаться к краю. Поэтому было бы неплохо, если бы мы запретили суше подниматься выше уровня воды рядом с краем карты.

Размер границы


Насколько близко суша должна подбираться к краю карты? На этот вопрос нет правильного ответа, поэтому сделаем этот параметр настраиваемым. Мы добавим два ползунка к компоненту HexMapGenerator, один для границ вдоль краёв по оси X, другой для границ вдоль оси Z. Так мы сможем использовать более широкую границу в одном из измерений, или вообще создавать границу только в одном измерении. Давайте используем интервал от 0 до 10 со значением по умолчанию 5.

  [Range(0, 10)]
        public int mapBorderX = 5;

        [Range(0, 10)]
        public int mapBorderZ = 5;


6948abc743e881596bdea5656e562943.png


Ползунки границ карты.

Ограничиваем центры участков суши


Без наличия границ допустимыми являются все ячейки. Когда границы есть, минимально допустимые координаты смещения увеличиваются, а максимально допустимые координаты уменьшаются. Так как для генерации участков нам нужно будет знать допустимый интервал, давайте будем отслеживать его с помощью четырёх целочисленных полей.

  int xMin, xMax, zMin, zMax;


Инициализируем ограничения в GenerateMap перед созданием суши. Мы используем эти значения как параметры для вызовов Random.Range, поэтому максимумы на самом деле являются исключительными. Без границы они равны количеству ячеек измерения, поэтому не минус 1.

  public void GenerateMap (int x, int z) {
                …
                for (int i = 0; i < cellCount; i++) {
                        grid.GetCell(i).WaterLevel = waterLevel;
                }
                xMin = mapBorderX;
                xMax = x - mapBorderX;
                zMin = mapBorderZ;
                zMax = z - mapBorderZ;
                CreateLand();
                …
        }


Мы не будем строго запрещать появление суши за краем границы, потому что при этом создавались бы резко обрезанные края. Вместо этого мы ограничим только ячейки, используемые для начала генерации участков. То есть приблизительные центры участков будут ограничены, но части участков смогут выходить за область границы. Это можно сделать, изменив GetRandomCell так, чтобы он выбирал ячейку в интервале допустимых смещений.

  HexCell GetRandomCell () {
//              return grid.GetCell(Random.Range(0, cellCount));
                return grid.GetCell(Random.Range(xMin, xMax), Random.Range(zMin, zMax));
        }


7a8bbf24b1d46d9b97e8e9c01652fdfd.jpg


a310cfaa2bec1cbe019a08c14218c7fd.jpg


7f1799f5058c0d1e435bfc3c42133b9c.jpg


93ac4b1b771eaa896b6c494b69c18b92.jpg


Границы карты размерами 0×0, 5×5, 10×10 и 0×10.

Когда всем параметрам карты установлены значения по умолчанию, граница размером 5 будет надёжно защищать край карты от касания суши. Однако это не гарантировано. Суша иногда может подбираться близко к краю, а иногда и касаться его в нескольких местах.

Вероятность того, что суша пересечёт всю границу, зависит и от размера границы, и от максимального размера участка. Без колебаний участки остаются шестиугольниками. Полный шестиугольник с радиусом $r$ содержит $3r^2+3r+1$ ячеек. Если существуют шестиугольники с радиусом, равным размеру границы, то они смогут её пересечь. Полный шестиугольник с радиусом 5 содержит 91 ячейку. Так как по умолчанию максимум равен 100 ячейкам на участок, то это значит, что суша сможет проложить проложить мост через 5 ячеек, особенно если присутствуют колебания. Чтобы этого точно не происходило, или уменьшим максимальный размер участка, или увеличим размер границы.

Как выведена формула количества ячеек в шестиугольной области?
При радиусе 0 мы имеем дело с одной ячейкой. Отсюда взялась 1. При радиусе 1 вокруг центра есть шесть дополнительных ячеек, то есть $6+1$. Можно считать эти шесть ячеек концами шести треугольников, касающихся центра. При радиусе 2 к этим треугольникам добавляется второй ряд, то есть на треугольник получается ещё две ячейки, и всего $6(1+2)+1$. При радиусе 3 добавляется третий ряд, то есть ещё три ячейки на треугольник, и всего $6(1+2+3)+1$. И так далее. То есть в общем виде формула выглядит как $6(sum_(i=1)^r i)+1 = 6((r(r+1))/2)+1 = 3r(r+1)+1=3r^2+3r+1$.


Чтобы чётче это увидеть, мы можем присвоить размеру границы значение 200. Так как полный шестиугольник с радиусом 8 содержит 217 ячеек, суша с большой вероятностью коснётся края карты. По крайней мере, если использовать значение размера границы по умолчанию (5). При увеличении границы до 10 вероятность сильно понизится.

74b75deb060d9bdae2f74c1db4de287e.jpg


ffa7cee967f81ba9fe4ddc6718519d9a.jpg


Участок суши имеет постоянный размер 200, границы карты равны 5 и 10.

Пангея


Учтите, что при увеличении границы карты и сохранении того же процента суши мы заставляем сушу формировать меньшую область. В результате этого крупная карта по умолчанию с большой вероятностью создаст единую большую массу суши — сверхконтинент Пангею — возможно, с несколькими мелкими островами. При увеличении размера границы вероятность этого повышается, а при определённых значениях мы почти гарантированно получим сверхконтинент. Однако когда процент суши слишком велик, большинство доступных областей заполняется и в результате у нас получается почти прямоугольная масса суши. Чтобы такого не происходило, нужно снизить процент суши.

e1bd8420915ccc74cd3470409b9a4236.jpg


40% суши с границей карты 10.

Откуда взялось название Пангея?

Так назывался последний известный сверхконтинент, существовавший на Земле много лет назад. Название составлено из греческих слов pan и Gaia, означающих что-то вроде «вся природа» или «вся суша».

Защищаемся от невозможных карт


Мы генерируем нужное количество суши, просто продолжая поднимать участки, пока не достигнем нужной массы суши. Это срабатывает, потому что рано или поздно мы поднимем на уровнем воды каждую ячейку. Однако при использовании границы карты мы не можем достичь каждую ячейку. Когда требуется слишком высокий процент суши, то это приведёт к бесконечным «попыткам и неудачам» генератора поднять больше суши, и он застрянет в бесконечном цикле. При этом приложение зависнет, а этого не должно происходить.

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

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

  void CreateLand () {
                int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
//              while (landBudget > 0) {
                for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
                        int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
                        …
                }
        }


Если у нас получится испорченная карта, то выполнение 10000 итераций не потребует много времени, потому что многие ячейки быстро достигнут максимальной высоты, что помешает расти новым участкам.

Даже после прерывания цикла у нас всё равно получится правильная карта. На ней просто не окажется нужного количества суши и выглядеть будет она не очень интересно. Давайте выведем в консоль уведомление об этом, сообщив, какую оставшуюся сумму суши нам не удалось потратить.

  void CreateLand () {
                …
                if (landBudget > 0) {
                        Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
                }
        }


ab587ff4f560fe6c1c0d7d5a547ae2b7.jpg


95% суши с границей карты 10 не смогли потратить всю сумму.

Почему неудавшаяся карта всё равно обладает вариативностью?

Линия побережья имеет вариативность, потому что когда высоты внутри области создания становятся слишком высокими, новые участки не дают им разрастаться наружу. Тот же принцип не позволяет участкам вырастать в небольшие районы суши, пока не достигшие максимальной высоты и просто оказавшиеся пропавшими. Кроме того, вариативность увеличивается при опускании участков.


unitypackage

Разбиение карты на части


Теперь, когда у нас есть граница карты, мы по сути разделили карту на два отдельных региона: регион границы и регион создания участков. Так как нам важен только регион создания, мы можем считать такой случай ситуацией с одним регионом. Регион просто не покрывает всю карту. Но если это невозможно, то ничто не мешает нам разделить карту на несколько несоединённых регионов создания суши. Это позволит массам суши формироваться независимо друг от друга, обозначая разные континенты.

Регион карты


Давайте начнём с том, что опишем один регион карты как struct. Это упростит нам работу с несколькими регионами. Создадим для этого структуру MapRegion, которая просто содержит поля границ региона. Так как мы не будем использовать эту структуру за пределами HexMapGenerator, то можем определить её внутри этого класса как частную внутреннюю структуру. Тогда четыре целочисленных поля можно заменить одним полем MapRegion.

//        int xMin, xMax, zMin, zMax;
        struct MapRegion {
                public int xMin, xMax, zMin, zMax;
        }

        MapRegion region;


Чтобы всё работало, нам нужно в GenerateMap добавить полям минимума-максимума префикс region..

          region.xMin = mapBorderX;
                region.xMax = x - mapBorderX;
                region.zMin = mapBorderZ;
                region.zMax = z - mapBorderZ;


А также в GetRandomCell.

  HexCell GetRandomCell () {
                return grid.GetCell(
                        Random.Range(region.xMin, region.xMax),
                        Random.Range(region.zMin, region.zMax)
                );
        }


Несколько регионов


Для поддержки нескольких регионов заменим одно поле MapRegion списком регионов.

//        MapRegion region;
        List regions;


На этом этапе неплохо будет добавить отдельный метод для создания регионов. Он должен создавать нужный список или очищать его, если он уже существует. После этого он будет определять один регион, как мы это делали раньше, и добавлять его в список.

  void CreateRegions () {
                if (regions == null) {
                        regions = new List();
                }
                else {
                        regions.Clear();
                }

                MapRegion region;
                region.xMin = mapBorderX;
                region.xMax = grid.cellCountX - mapBorderX;
                region.zMin = mapBorderZ;
                region.zMax = grid.cellCountZ - mapBorderZ;
                regions.Add(region);
        }


Вызовем этот метод в GenerateMap, а не будем создавать регион напрямую.

//                region.xMin = mapBorderX;
//              region.xMax = x - mapBorderX;
//              region.zMin = mapBorderZ;
//              region.zMax = z - mapBorderZ;
                CreateRegions();
                CreateLand();


Чтобы GetRandomCell мог работать с произвольным регионом, дадим ему параметр MapRegion.

  HexCell GetRandomCell (MapRegion region) {
                return grid.GetCell(
                        Random.Range(region.xMin, region.xMax),
                        Random.Range(region.zMin, region.zMax)
                );
        }


Теперь методы RaiseTerraion и SinkTerrain должны передать соответствующий регион в GetRandomCell. Чтобы сделать это, каждому из них тоже нужен параметр региона.

  int RaiseTerrain (int chunkSize, int budget, MapRegion region) {
                searchFrontierPhase += 1;
                HexCell firstCell = GetRandomCell(region);
                …
        }

        int SinkTerrain (int chunkSize, int budget, MapRegion region) {
                searchFrontierPhase += 1;
                HexCell firstCell = GetRandomCell(region);
                …
        }


Метод CreateLand должен определять, для каждого региона нужно поднимать или опускать участки. Чтобы сбалансировать сушу между регионами, мы просто будем многократно обходить в цикле список регионов.

  void CreateLand () {
                int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
                for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
                        for (int i = 0; i < regions.Count; i++) {
                                MapRegion region = regions[i];
                                int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
                                if (Random.value < sinkProbability) {
                                        landBudget = SinkTerrain(chunkSize, landBudget, region);
                                }
                                else {
                                        landBudget = RaiseTerrain(chunkSize, landBudget, region);
                                }
                        }
                }
                if (landBudget > 0) {
                        Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
                }
        }


Однако нам нужно ещё сделать так, чтобы опускание участков распределилось равномерно. Это можно сделать, одновременно решая для всех регионов, нужно ли их опускать.

          for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
                        bool sink = Random.value < sinkProbability;
                        for (int i = 0; i < regions.Count; i++) {
                                MapRegion region = regions[i];
                                int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
//                              if (Random.value < sinkProbability) {
                                if (sink) {
                                        landBudget = SinkTerrain(chunkSize, landBudget, region);
                                }
                                else {
                                        landBudget = RaiseTerrain(chunkSize, landBudget, region);
                                }
                        }
                }


Наконец, чтобы использовать ровно всю сумму суши, нам нужно останавливать процесс, как только сумма достигнет нуля. Это может произойти на любом этапе цикла региона. Поэтому переместим проверку на нулевую сумму во внутренний цикл. На самом деле мы можем выполнять эту проверку только после поднятия суши, потому что при опускании сумма никогда не тратится. Если мы закончили, то можем сразу же выйти из метода CreateLand.

//                for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
                for (int guard = 0; guard < 10000; guard++) {
                        bool sink = Random.value < sinkProbability;
                        for (int i = 0; i < regions.Count; i++) {
                                MapRegion region = regions[i];
                                int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
                                        if (sink) {
                                        landBudget = SinkTerrain(chunkSize, landBudget, region);
                                }
                                else {
                                        landBudget = RaiseTerrain(chunkSize, landBudget, region);
                                        if (landBudget == 0) {
                                                return;
                                        }
                                }
                        }
                }


Два региона


Хоть у нас теперь и есть поддержка нескольких регионов, мы по-прежнему задаём только один. Давайте изменим CreateRegions так, чтобы он делил карту пополам по вертикали. Для этого вдвое уменьшим значение xMax добавляемого региона. Затем используем то же значение для xMin и снова используем исходное значение для xMax, использовав его как второй регион.

          MapRegion region;
                region.xMin = mapBorderX;
                region.xMax = grid.cellCountX / 2;
                region.zMin = mapBorderZ;
                region.zMax = grid.cellCountZ - mapBorderZ;
                regions.Add(region);
                region.xMin = grid.cellCountX / 2;
                region.xMax = grid.cellCountX - mapBorderX;
                regions.Add(region);


Генерация карт на этом этапе не даст никакой разницы. Хоть мы и определили два региона, они занимают ту же область, что и один старый регион. Чтобы развести их в стороны, нужно оставить между ними пустое пространство. Это можно сделать, добавив ползунок границы региона, использовав тот же интервал и значение по умолчанию, что и для границ карты.

  [Range(0, 10)]
        public int regionBorder = 5;


9ffcd2cd0f4068caf622e433c503715d.png


Ползунок границы региона.

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

Чтобы применить эту границу региона, вычтем её из xMax первого региона и прибавим к xMin второго региона.

          MapRegion region;
                region.xMin = mapBorderX;
                region.xMax = grid.cellCountX / 2 - regionBorder;
                region.zMin = mapBorderZ;
                region.zMax = grid.cellCountZ - mapBorderZ;
                regions.Add(region);
                region.xMin = grid.cellCountX / 2 + regionBorder;
                region.xMax = grid.cellCountX - mapBorderX;
                regions.Add(region);


1e51f6476894662d55f7ca9748dc7fa5.jpg


Карта разделена по вертикали на два региона.

При параметрах по умолчанию будут создаваться два заметно разделённых региона, однако как и в случае с одним регионом и большой границей карты, мы не гарантированно получим ровно две массы суши. Чаще всего это будет два больших континента, возможно с несколькими островами. Но иногда в регионе может создаваться два или более крупных островов. А иногда два континента могут быть соединены перешейком.

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

          MapRegion region;
                if (Random.value < 0.5f) {
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX / 2 - regionBorder;
                        region.zMin = mapBorderZ;
                        region.zMax = grid.cellCountZ - mapBorderZ;
                        regions.Add(region);
                        region.xMin = grid.cellCountX / 2 + regionBorder;
                        region.xMax = grid.cellCountX - mapBorderX;
                        regions.Add(region);
                }
                else {
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX - mapBorderX;
                        region.zMin = mapBorderZ;
                        region.zMax = grid.cellCountZ / 2 - regionBorder;
                        regions.Add(region);
                        region.zMin = grid.cellCountZ / 2 + regionBorder;
                        region.zMax = grid.cellCountZ - mapBorderZ;
                        regions.Add(region);
                }


f23970c91cdd9c539ebdc8c7fdbba05f.jpg


Карта, горизонтально разделённая на два региона.

Так как мы используем широкую карту, при горизонтальном разделении будут создаваться более широкие и тонкие регионы. В результате эти регионы с большей вероятностью образуют несколько разделённых масс суши.

Четыре региона


Давайте сделаем количество регионов настраиваемым, создадим поддержку от 1 до 4 регионов.

  [Range(1, 4)]
        public int regionCount = 1;


91849e2a4ffe6091b8d4fdf0d78b0c45.png


Ползунок для количества регионов.

Мы можем использовать оператор switch для выбора выполнения соответствующего кода регионов. Начнём с повторения кода одного региона, который будет использоваться по умолчанию, а код двух регионов оставим для case 2.

          MapRegion region;
                switch (regionCount) {
                default:
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX - mapBorderX;
                        region.zMin = mapBorderZ;
                        region.zMax = grid.cellCountZ - mapBorderZ;
                        regions.Add(region);
                        break;
                case 2:
                        if (Random.value < 0.5f) {
                                region.xMin = mapBorderX;
                                region.xMax = grid.cellCountX / 2 - regionBorder;
                                region.zMin = mapBorderZ;
                                region.zMax = grid.cellCountZ - mapBorderZ;
                                regions.Add(region);
                                region.xMin = grid.cellCountX / 2 + regionBorder;
                                region.xMax = grid.cellCountX - mapBorderX;
                                regions.Add(region);
                        }
                        else {
                                region.xMin = mapBorderX;
                                region.xMax = grid.cellCountX - mapBorderX;
                                region.zMin = mapBorderZ;
                                region.zMax = grid.cellCountZ / 2 - regionBorder;
                                regions.Add(region);
                                region.zMin = grid.cellCountZ / 2 + regionBorder;
                                region.zMax = grid.cellCountZ - mapBorderZ;
                                regions.Add(region);
                        }
                        break;
                }


Что за оператор switch?
Это альтернатива написанию последовательности операторов if-else-if-else. switch применяется к переменной, а метки используются для обозначения того, какой код нужно выполнять. Существует также метка default, которая используется как последний блок else. Каждый вариант должен завершаться или оператором break, или оператором return.

Чтобы блок switch оставался удобочитаемым, обычно лучше делать все case короткими, в идеале — одним оператором или вызовом метода. Я не буду делать это для примера кода региона, но если вы хотите создать более интересные регионы, то рекомендую вам использовать отдельные методы. Например:

          switch (regionCount) {
                        default: CreateOneRegion(); break;
                        case 2: CreateTwoRegions(); break;
                        case 3: CreateThreeRegions(); break;
                        case 4: CreateFourRegions(); break;
                }


Три региона похожи на два, только вместо половин используются трети. В этом случае горизонтальное разделение создаст слишком узкие регионы, поэтому мы создали поддержку только вертикального разделения. Заметьте, что в результате у нас получается удвоенное пространство границы региона, поэтому пространства для создания новых участков меньше, чем в случае двух регионов.

          switch (regionCount) {
                default:
                        …
                        break;
                case 2:
                        …
                        break;
                case 3:
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX / 3 - regionBorder;
                        region.zMin = mapBorderZ;
                        region.zMax = grid.cellCountZ - mapBorderZ;
                        regions.Add(region);
                        region.xMin = grid.cellCountX / 3 + regionBorder;
                        region.xMax = grid.cellCountX * 2 / 3 - regionBorder;
                        regions.Add(region);
                        region.xMin = grid.cellCountX * 2 / 3 + regionBorder;
                        region.xMax = grid.cellCountX - mapBorderX;
                        regions.Add(region);
                        break;
                }


2342e7cc8cb524f4f9db803d5359a78e.jpg


Три региона.

Четыре региона можно создать комбинацией горизонтального и вертикального разделения и добавлением одного региона в каждый угол карты.

          switch (regionCount) {
                …
                case 4:
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX / 2 - regionBorder;
                        region.zMin = mapBorderZ;
                        region.zMax = grid.cellCountZ / 2 - regionBorder;
                        regions.Add(region);
                        region.xMin = grid.cellCountX / 2 + regionBorder;
                        region.xMax = grid.cellCountX - mapBorderX;
                        regions.Add(region);
                        region.zMin = grid.cellCountZ / 2 + regionBorder;
                        region.zMax = grid.cellCountZ - mapBorderZ;
                        regions.Add(region);
                        region.xMin = mapBorderX;
                        region.xMax = grid.cellCountX / 2 - regionBorder;
                        regions.Add(region);
                        break;
                }
        }


551b72a0ca2f12a5636828faa4c3febc.jpg


Четыре региона.

Использованный здесь подход — это самый простейший способ разделения карты. Он генерирует примерно одинаковые по массе суши регионы, а их вариативность контролируется другими параметрами генерации карты. Однако при этом всегда будет довольно очевидно, что карта была разделена по прямым линиям. Чем больше контроля нам нужно, тем менее органично будет выглядеть результат. Поэтому это нормально, если для геймплея вам нужны примерно равные регионы. Но если вам нужна наиболее вариативная и неограниченная суша, то придётся сделать её с помощью одного региона.

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

unitypackage

Эрозия


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

  public void GenerateMap (int x, int z) {
                …
                CreateRegions();
                CreateLand();
                ErodeLand();
                SetTerrainType();
                …
        }
        
        …
        
        void ErodeLand () {}


Процент эрозии


Чем больше времени проходит, тем больше появляется эрозии. Поэтому мы хотим, чтобы эрозия была не постоянной, а настраиваемой. При минимуме эрозия равна нулю, что соответствует созданным ранее картам. При максимуме эрозия всеобъемлюща, то есть дальнейшее приложение сил эрозии больше не будет изменять рельеф. То есть параметр эрозии должен быт процентом от 0 до 100, а по умолчанию мы возьмём 50.

  [Range(0, 100)]
        public int erosionPercentage = 50;


c4eefee5e18aeee36f16e6aee98a27fe.png


Ползунок эрозии.

Поиск разрушаемых эрозией ячеек


Эрозия делает рельеф более плавным. В нашем случае единственными острыми частями являются обрывы. Поэтому целью эрозионного процесса будут они. Если обрыв существует, эрозия должна его уменьшать, пока он в конце концов не превратится в склон. Мы не будем сглаживать склоны, потому что это приведёт к скучному рельефу. Чтобы это сделать, нам нужно определить, какие ячейки находятся на вершинах обрывов, и снизить их высоту. Это будут подверженные эрозии ячейки.

Давайте создадим метод, определяющий, может ли ячейка быть подверженной эрозии. Он определяет это, проверяя соседей ячейки, пока не найдёт достаточно большую разность высот. Так как для обрывов нужна разность не менее одного-двух уровней высоты, то ячейка подвержена эрозии, если один или несколько её соседей как минимум на два шага ниже неё. Если такого соседа нет, то ячейка не может подвергнуться эрозии.

  bool IsErodible (HexCell cell) {
                int erodibleElevation = cell.Elevation - 2;
                for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
                        HexCell neighbor = cell.GetNeighbor(d);
                        if (neighbor && neighbor.Elevation <= erodibleElevation) {
                                return true;
                        }
                }
                return false;
        }


Мы можем использовать этот метод в ErodeLand, чтобы обойти в цикле все ячейки и записать все подверженные эрозии ячейки во временный список.

  void ErodeLand () {
                List erodibleCells = ListPool.Get();
                for (int i = 0; i < cellCount; i++) {
                        HexCell cell = grid.GetCell(i);
                        if (IsErodible(cell)) {
                                erodibleCells.Add(cell);
                        }
                }

                ListPool.Add(erodibleCells);
        }


Как только мы узнаем общее количество подверженных эрозии ячеек, то сможем использовать процент эрозии для определения количества оставшихся подверженных эрозии ячеек. Например, если процент равен 50, то мы должны подвергать ячейки эрозии, пока от исходного количества не останется половина. Если процент равен 100, то мы не остановимся, пока не уничтожим все подверженные эрозии ячейки.

  void ErodeLand () {
                List erodibleCells = ListPool.Get();
                for (int i = 0; i < cellCount; i++) {
                        …
                }

                int targetErodibleCount =
                        (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);

                ListPool.Add(erodibleCells);
        }


Разве мы не должны учитывать только подверженные эрозии ячейки суши?

Эрозия происходит и под водой. Существуют разные виды эрозии, но нам не стоит вдаваться в детали, достаточно будет единого общего подхода.


Снижение ячеек


Давайте начнём с наивного подхода и предположим, что простое снижение высоты разрушаемой эрозией ячейки сделает её больше не подверженной эрозии. Если бы это было правдой, то мы могли бы просто брать из списка случайные ячейки, снижать их высоту, а затем удалять их из списка. Эту операцию мы повторяли бы, пока не достигли бы нужного количества подверженных эрозии ячеек.

          int targetErodibleCount =
                        (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);
                
                while (erodibleCells.Count > targetErodibleCount) {
                        int index = Random.Range(0, erodibleCells.Count);
                        HexCell cell = erodibleCells[index];

                        cell.Elevation -= 1;

                        erodibleCells.Remove(cell);
                }

                ListPool.Add(erodibleCells);


Чтобы предотвратить поиск, требуемый erodibleCells.Remove, мы будем переписывать текущую ячейку последней в списке, а потом удалять последний элемент. Нам всё равно не важен их порядок.

//                        erodibleCells.Remove(cell);
                        erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
                        erodibleCells.RemoveAt(erodibleCells.Count - 1);


50e05066c444e6854c622b865fde2726.jpg


14dfca6b41ce23cdeb3e5fc4d5307eba.jpg


Наивное понижение 0% и 100% подверженных эрозии ячеек, seed карты 1957632474.

Отслеживание эрозии


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

                  if (!IsErodible(cell)) {
                                erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
                                erodibleCells.RemoveAt(erodibleCells.Count - 1);
                        }


2810a9779b056865ad4490f9b00fa50f.jpg


100% эрозии при сохранении подверженных эрозии ячеек в списке.

Так мы получаем гораздо более сильную эрозию, но при использовании 100% мы всё равно не избавляемся от всех обрывов. Причина заключается в том, что после снижения высоты ячейки может стать подверженной эрозии один из её соседей. Поэтому в результате у нас может появиться больше подверженных эрозии ячеек, чем было изначально.

После опускания ячейки нам нужно проверить всех её соседей. Если теперь они подвержены эрозии, но их ещё нет в списке, то нужно добавить их туда.

                  if (!IsErodible(cell)) {
                                erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
                                erodibleCells.RemoveAt(erodibleCells.Count - 1);
                        }
                        
                        for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
                                HexCell neighbor = cell.GetNeighbor(d);
                                if (
                                        neighbor && IsErodible(neighbor) &&
                                        !erodibleCells.Contains(neighbor)
                                ) {
                                        erodibleCells.Add(neighbor);
                                }
                        }


b2a894ed5274286b00046050963388f5.jpg


Все подверженные эрозии ячейки опущены.

Сохраняем массу суши


Теперь процесс эрозии может продолжаться, пока не исчезнут все обрывы. Это значительно влияет на сушу. Большая часть массы суши исчезла и у нас получилось гораздо меньший чем нужно процент суши. Так получилось, потому что мы удаляем сушу с карты.

Настоящая эрозия не уничтожает материю. Она берёт её из одного места и располагает где-то ещё. Мы можем сделать так же. При снижении одной ячейки мы должны поднять одного из её соседей. По сути один уровень высоты переносится на более низкую ячейку. Это сохраняет общую сумму высот карты, при этом просто её сглаживая.

Чтобы реализовать это, нам нужно решить, куда перемещать продукты эрозии. Это будет нашей целевой точкой эрозии. Давайте создадим метод для определения целевой точки ячейки, которая будет подвергнута эрозии. Так как эта ячейка содержит обрыв, то логично будет в качестве цели выбрать ячейку, находящуюся под этим обрывом. Но подверженная эрозии ячейка может иметь несколько обрывов, поэтому проверим всех соседей и поместим всех кандидатов во временный список, а затем будем выбирать один из них случайным образом.

  HexCell GetErosionTarget (HexCell cell) {
                List candidates = ListPool.Get();
                int erodibleElevation = cell.Elevation - 2;
                for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
                        HexCell neighbor = cell.GetNeighbor(d);
                        if (neighbor && neighbor.Elevation <= erodibleElevation) {
                                candidates.Add(neighbor);
                        }
                }
                HexCell target = candidates[Random.Range(0, candidates.Count)];
                ListPool.Add(candidates);
                return target;
        }


В ErodeLand мы определяем целевую ячейку сразу после выбора подвергаемой эрозии ячейки. Затем уменьшаем и увеличиваем высоты ячеек сразу друг за другом. При этом целевая ячейка сама может стать подверженной эрозии, но эта ситуация разрешается, когда мы проверяем соседей только что подвергнутой эрозии ячейки.

                  HexCell cell = erodibleCells[index];
                        HexCell targetCell = GetErosionTarget(cell);

                        cell.Elevation -= 1;
                        targetCell.Elevation += 1;

                        if (!IsErodible(cell)) {
                                erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
                                erodibleCells.RemoveAt(erodibleCells.Count - 1);
                        }


Так как мы подняли целевую ячейку, часть соседей этой ячейки может перестать быть подверженной эрозии. Надо обойти их и проверить, подвержены ли они эрозии. Если нет, но они находятся в списке, то нужно удалить их из него.

                  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
                                HexCell neighbor = cell.GetNeighbor(d);
                                …
                        }

                        for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
                                HexCell neighbor = targetCell.GetNeighbor(d);
                                if (
                                        neighbor && !IsErodible(neighbor) &&
                                        erodibleCells.Contains(neighbor)
                                ) {
                                        erodibleCells.Remove(neighbor);
                                }
                        }


d7184f0b4a2dd34853b56e0024ef2b6c.jpg


100% эрозии с сохранением массы суши.

Теперь эрозия может сглаживать рельеф гораздо лучше, опуская некоторые области и поднимая другие. В результате масса суши может и увеличиваться, и сужаться. Это может изменять процент суши на несколько процентов в одну или другую сторону, но серьёзные отклонения возникают редко. То есть чем больше эрозии мы применим, тем меньше контроля у нас будет над получившимся процентом суши.

Ускоренная эрозия


Хоть нам и не нужно особо заботиться об эффективности алгоритма эрозии, можно внести в него простые улучшения. Во-первых, заметим, что мы явным образом проверяем, можно ли подвергнуть эрозии ячейку

© Habrahabr.ru