Главная » Хабрахабр » [Перевод] Карты из шестиугольников в Unity: вода, объекты рельефа и крепостные стены

[Перевод] Карты из шестиугольников в Unity: вода, объекты рельефа и крепостные стены

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

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

  • Добавляем в ячейки воду.
  • Триангулируем поверхность воды.
  • Создаём прибой с пеной.
  • Объединяем воду и реки.

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

Вода прибывает.

Уровень воды

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

public int WaterLevel set { if (waterLevel == value) { return; } waterLevel = value; Refresh(); } } int waterLevel;

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

Затопление ячеек

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

public bool IsUnderwater { get { return waterLevel > elevation; } }

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

// public const float riverSurfaceElevationOffset = -0.5f; public const float waterElevationOffset = -0.5f;

Изменим HexCell.RiverSurfaceY так, чтобы он использовал новое имя. Затем добавим похожее свойство к поверхности воды затопленной ячейки.

public float RiverSurfaceY { get { return (elevation + HexMetrics.waterElevationOffset) * HexMetrics.elevationStep; } } public float WaterSurfaceY { get { return (waterLevel + HexMetrics.waterElevationOffset) * HexMetrics.elevationStep; } }

Редактирование воды

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

int activeElevation; int activeWaterLevel; … bool applyElevation = true; bool applyWaterLevel = true;

Добавим методы для соединения этих параметров с UI.

public void SetApplyWaterLevel (bool toggle) { applyWaterLevel = toggle; } public void SetWaterLevel (float level) { activeWaterLevel = (int)level; }

И добавим уровень воды в EditCell.

void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (applyWaterLevel) { cell.WaterLevel = activeWaterLevel; } … } }

Чтобы добавить в UI уровень воды, дублируем метку и ползунок высоты, а потом изменим их. Не забудьте прикрепить их события к соответствующим методам.

Ползунок уровня воды.

unitypackage

Триангуляция воды

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

Shader "Custom/Water" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Albedo (RGB)", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent" } LOD 200 CGPROGRAM #pragma surface surf Standard alpha #pragma target 3.0 sampler2D _MainTex; struct Input { float2 uv_MainTex; }; half _Glossiness; half _Metallic; fixed4 _Color; void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = _Color; o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; } ENDCG } FallBack "Diffuse"
}

Создадим новый материал с этим шейдером, дублировав материал Water и заменив его шейдер. Оставим текстуру шума, потому что позже её используем.

Материал Water.

Ему не нужны UV-координаты, и он должен использовать материал Water. Добавим к префабу новый дочерний объект, дублировав дочерний объект Rivers. После этого избавимся от экземпляра. Как обычно, сделаем это, создав экземпляр префаба, изменив его, а затем применив изменения к префабу.

Дочерний объект Water.

Далее добавим в HexGridChunk поддержку меша воды.

public HexMesh terrain, rivers, roads, water; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); water.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); water.Apply(); }

И соединим его с дочерним объектом префаба.

Объект Water соединён.

Шестиугольники воды

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

void Triangulate (HexDirection direction, HexCell cell) { … if (cell.IsUnderwater) { TriangulateWater(direction, cell, center); } } void TriangulateWater ( HexDirection direction, HexCell cell, Vector3 center ) { }

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

void TriangulateWater ( HexDirection direction, HexCell cell, Vector3 center ) { center.y = cell.WaterSurfaceY; Vector3 c1 = center + HexMetrics.GetFirstSolidCorner(direction); Vector3 c2 = center + HexMetrics.GetSecondSolidCorner(direction); water.AddTriangle(center, c1, c2); }

Шестиугольники воды.

Соединения воды

Мы можем соединить соседние клетки с водой одним четырёхугольником.

water.AddTriangle(center, c1, c2); if (direction <= HexDirection.SE) { HexCell neighbor = cell.GetNeighbor(direction); if (neighbor == null || !neighbor.IsUnderwater) { return; } Vector3 bridge = HexMetrics.GetBridge(direction); Vector3 e1 = c1 + bridge; Vector3 e2 = c2 + bridge; water.AddQuad(c1, c2, e1, e2); }

Соединения краёв воды.

И заполним углы одним треугольником.

if (direction <= HexDirection.SE) { … water.AddQuad(c1, c2, e1, e2); if (direction <= HexDirection.E) { HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor == null || !nextNeighbor.IsUnderwater) { return; } water.AddTriangle( c2, e2, c2 + HexMetrics.GetBridge(direction.Next()) ); } }

Соединения углов воды.

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

Согласованные уровни воды

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

Несогласованные уровни воды.

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

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

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

unitypackage

Анимирование воды

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

Идеально плоская вода.

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

struct Input { float2 uv_MainTex; float3 worldPos; }; … void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.worldPos.xz; uv.y += _Time.y; float4 noise = tex2D(_MainTex, uv * 0.025); float waves = noise.z; fixed4 c = saturate(_Color + waves); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

Скроллинг воды, время ×10.

Два направления

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

float2 uv1 = IN.worldPos.xz; uv1.y += _Time.y; float4 noise1 = tex2D(_MainTex, uv1 * 0.025); float2 uv2 = IN.worldPos.xz; uv2.x += _Time.y; float4 noise2 = tex2D(_MainTex, uv2 * 0.025); float waves = noise1.z + noise2.x;

При суммировании обоих сэмплов мы получаем результаты в интервале 0–2, поэтому нам нужно масштабированием вернуть его обратно к 0–1. Вместо простого деления волн пополам, мы можем использовать функцию smoothstep для создания более интересного результата. Мы наложим ¾–2 на 0–1, чтобы на части поверхность воды не было видимых волн.

float waves = noise1.z + noise2.x; waves = smoothstep(0.75, 2, waves);

Два направления, время ×10.

Волны смешения

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

Мы будем это делать, складывая координаты мира X и Z и используя сумму как входные данные для функции sin. Волну смешения мы создадим с помощью синусоиды, которая диагонально перемещается по поверхности воды. И разумеется, прибавим то же значение, чтобы их анимировать. Уменьшим масштаб значений, чтобы получить достаточно большие полосы.

float blendWave = sin((IN.worldPos.x + IN.worldPos.z) * 0.1 + _Time.y);

Синусоиды колеблются в интервале от -1 и 1, а нам нужен интервал 0–1. Можно получить его, возведя волну в квадрат. Чтобы увидеть изолированный результат, используем его вместо изменённого цвета в качестве выходного значения.

sin((IN.worldPos.x + IN.worldPos.z) * 0.1 + _Time.y); blendWave *= blendWave; float waves = noise1.z + noise2.x; waves = smoothstep(0.75, 2, waves); fixed4 c = blendWave; //saturate(_Color + waves);

Волны смешения.

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

float blendWave = sin( (IN.worldPos.x + IN.worldPos.z) * 0.1 + (noise1.y + noise2.z) + _Time.y ); blendWave *= blendWave;

Искажённые волны смешения.

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

float waves = lerp(noise1.z, noise1.w, blendWave) + lerp(noise2.x, noise2.y, blendWave); waves = smoothstep(0.75, 2, waves); fixed4 c = saturate(_Color + waves);

Смешение волн, время ×2.

unitypackage

Побережье

Мы закончили с открытой водой, но теперь нам нужно заполнить пробел в воде вдоль берега. Так как мы должны соответствовать контурам суши, вода побережья требует другого подхода. Давайте разделим TriangulateWater на два метода — один для открытой воды, второй для побережья. Чтобы понимать, когда мы работаем с побережьем, нам нужно посмотреть на соседнюю ячейку. То есть в TriangulateWater мы будем получать соседа. Если сосед есть и он не находится под водой, то мы имеем дело с побережьем.

void TriangulateWater ( HexDirection direction, HexCell cell, Vector3 center ) { center.y = cell.WaterSurfaceY; HexCell neighbor = cell.GetNeighbor(direction); if (neighbor != null && !neighbor.IsUnderwater) { TriangulateWaterShore(direction, cell, neighbor, center); } else { TriangulateOpenWater(direction, cell, neighbor, center); } } void TriangulateOpenWater ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { Vector3 c1 = center + HexMetrics.GetFirstSolidCorner(direction); Vector3 c2 = center + HexMetrics.GetSecondSolidCorner(direction); water.AddTriangle(center, c1, c2); if (direction <= HexDirection.SE && neighbor != null) {
// HexCell neighbor = cell.GetNeighbor(direction);
// if (neighbor == null || !neighbor.IsUnderwater) {
// return;
// } Vector3 bridge = HexMetrics.GetBridge(direction); … } } void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { }

Триангуляции вдоль побережья нет.

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

void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { EdgeVertices e1 = new EdgeVertices( center + HexMetrics.GetFirstSolidCorner(direction), center + HexMetrics.GetSecondSolidCorner(direction) ); water.AddTriangle(center, e1.v1, e1.v2); water.AddTriangle(center, e1.v2, e1.v3); water.AddTriangle(center, e1.v3, e1.v4); water.AddTriangle(center, e1.v4, e1.v5); }

Вееры треугольников вдоль побережья.

Однако мы не обязаны ограничиваться только некоторыми направлениями, потому что мы вызываем TriangulateWaterShore только при встрече с побережьем, для которого полоса нужна всегда. Далее идёт полоса рёбер, как и в обычном рельефе.

water.AddTriangle(center, e1.v4, e1.v5); Vector3 bridge = HexMetrics.GetBridge(direction); EdgeVertices e2 = new EdgeVertices( e1.v1 + bridge, e1.v5 + bridge ); water.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); water.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); water.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); water.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

Полосы рёбер вдоль побережья.

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

water.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { water.AddTriangle( e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next()) ); }

Углы рёбер вдоль побережья.

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

UV побережья

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

Она нужна только для воды рядом с побережьем. У открытой воды нет UV-координат, и ей не нужна пена. Логично будет создать для каждого типа собственный меш. Поэтому требования к обоим типам воды довольно сильно отличаются. Поэтому добавим в HexGridChunk поддержку ещё одного объекта меша.

public HexMesh terrain, rivers, roads, water, waterShore; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); water.Clear(); waterShore.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); water.Apply(); waterShore.Apply(); }

Этот новый меш будет использовать TriangulateWaterShore.

void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { waterShore.AddTriangle( e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next()) ); } }

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

Объект Water shore и материал с UV.

Изменим шейдер Water Shore так, чтобы вместо воды он отображал UV-координаты.

fixed4 c = fixed4(IN.uv_MainTex, 1, 1);

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

Отдельный меш для побережья.

Со стороны воды присвоим ей значение 0, со стороны суши — значение 1. Давайте поместим информацию о побережье в координату V. Так как больше нам передавать ничего не нужно, все координаты U будут просто равны 0.

waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { waterShore.AddTriangle( e1.v5, e2.v5, e1.v5 + HexMetrics.GetBridge(direction.Next()) ); waterShore.AddTriangleUV( new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 0f) ); }

Переходы к побережьям, неправильные.

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

waterShore.AddTriangleUV( new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, nextNeighbor.IsUnderwater ? 0f : 1f) );

Переходы к побережьям, правильные.

Пена на побережье

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

void surf (Input IN, inout SurfaceOutputStandard o) { float shore = IN.uv_MainTex.y; float foam = shore; fixed4 c = saturate(_Color + foam); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

Линейная пена.

Чтобы пена была интереснее, умножим её на квадрат синусоиды.

float foam = sin(shore * 10); foam *= foam * shore;

Затухающая пена квадрата синусоиды.

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

float shore = IN.uv_MainTex.y; shore = sqrt(shore);

Пена становится гуще у берега.

Сделаем так, чтобы при приближении к берегу искажение становилось слабее. Добавим искажение, чтобы она выглядела естественнее. Так оно лучше будет соответствовать линии побережья.

float2 noiseUV = IN.worldPos.xz; float4 noise = tex2D(_MainTex, noiseUV * 0.015); float distortion = noise.x * (1 - shore); float foam = sin((shore + distortion) * 10); foam *= foam * shore;

Пена с искажениями.

И, разумеется, всё это анимируем: и синусоиду, и искажения.

float2 noiseUV = IN.worldPos.xz + _Time.y * 0.25; float4 noise = tex2D(_MainTex, noiseUV * 0.015); float distortion = noise.x * (1 - shore); float foam = sin((shore + distortion) * 10 - _Time.y); foam *= foam * shore;

Анимированная пена.

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

float distortion1 = noise.x * (1 - shore); float foam1 = sin((shore + distortion1) * 10 - _Time.y); foam1 *= foam1; float distortion2 = noise.y * (1 - shore); float foam2 = sin((shore + distortion2) * 10 + _Time.y + 2); foam2 *= foam2 * 0.7; float foam = max(foam1, foam2) * shore;

Прибывающая и отступающая пена.

Смешение волн и пены

Между открытой и побережной водой есть резкий переход, потому что волны открытой воды не включены в воду побережья. Чтобы это исправить, нам нужно включить эти волны в шейдер Water Shore.

Фактически мы вставляем в него код и для пены, и для волн, каждый как отдельную функцию. Вместо того, чтобы копировать код волн, давайте вставим его в include-файл Water.cginc.

Как работают include-файлы шейдера?

Создание собственных include-файлов шейдеров рассматривается в туториале Rendering 5, Multiple Lights.

float Foam (float shore, float2 worldXZ, sampler2D noiseTex) {
// float shore = IN.uv_MainTex.y; shore = sqrt(shore); float2 noiseUV = worldXZ + _Time.y * 0.25; float4 noise = tex2D(noiseTex, noiseUV * 0.015); float distortion1 = noise.x * (1 - shore); float foam1 = sin((shore + distortion1) * 10 - _Time.y); foam1 *= foam1; float distortion2 = noise.y * (1 - shore); float foam2 = sin((shore + distortion2) * 10 + _Time.y + 2); foam2 *= foam2 * 0.7; return max(foam1, foam2) * shore;
} float Waves (float2 worldXZ, sampler2D noiseTex) { float2 uv1 = worldXZ; uv1.y += _Time.y; float4 noise1 = tex2D(noiseTex, uv1 * 0.025); float2 uv2 = worldXZ; uv2.x += _Time.y; float4 noise2 = tex2D(noiseTex, uv2 * 0.025); float blendWave = sin( (worldXZ.x + worldXZ.y) * 0.1 + (noise1.y + noise2.z) + _Time.y ); blendWave *= blendWave; float waves = lerp(noise1.z, noise1.w, blendWave) + lerp(noise2.x, noise2.y, blendWave); return smoothstep(0.75, 2, waves);
}

Изменим шейдер Water так, чтобы он использовал новый include-файл.

#include "Water.cginc" sampler2D _MainTex; … void surf (Input IN, inout SurfaceOutputStandard o) { float waves = Waves(IN.worldPos.xz, _MainTex); fixed4 c = saturate(_Color + waves); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

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

#include "Water.cginc" sampler2D _MainTex; … void surf (Input IN, inout SurfaceOutputStandard o) { float shore = IN.uv_MainTex.y; float foam = Foam(shore, IN.worldPos.xz, _MainTex); float waves = Waves(IN.worldPos.xz, _MainTex); waves *= 1 - shore; fixed4 c = saturate(_Color + max(foam, waves)); o.Albedo = c.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = c.a; }

Смешение пены и волн.

unitypackage

Снова о прибрежной воде

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

Почти скрытая прибрежная вода.

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

8. Коэффициент целостности равен 0. 6. Чтобы удвоить размер соединений воды нам нужно присвоить коэффициенту воды значение 0.

public const float waterFactor = 0.6f; public static Vector3 GetFirstWaterCorner (HexDirection direction) { return corners[(int)direction] * waterFactor; } public static Vector3 GetSecondWaterCorner (HexDirection direction) { return corners[(int)direction + 1] * waterFactor; }

Воспользуемся этими новыми методами в HexGridChunk, чтобы найти углы воды.

void TriangulateOpenWater ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { Vector3 c1 = center + HexMetrics.GetFirstWaterCorner(direction); Vector3 c2 = center + HexMetrics.GetSecondWaterCorner(direction); … } void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { EdgeVertices e1 = new EdgeVertices( center + HexMetrics.GetFirstWaterCorner(direction), center + HexMetrics.GetSecondWaterCorner(direction) ); … }

Использование углов воды.

Теперь HexMetrics также должен иметь метод создания мостов в воде. Расстояние между шестиугольниками воды и в самом деле удвоилось.

public const float waterBlendFactor = 1f - waterFactor; public static Vector3 GetWaterBridge (HexDirection direction) { return (corners[(int)direction] + corners[(int)direction + 1]) * waterBlendFactor; }

Изменим HexGridChunk так, чтобы он использовал новый метод.

void TriangulateOpenWater ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (direction <= HexDirection.SE && neighbor != null) { Vector3 bridge = HexMetrics.GetWaterBridge(direction); … if (direction <= HexDirection.E) { … water.AddTriangle( c2, e2, c2 + HexMetrics.GetWaterBridge(direction.Next()) ); } } } void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … Vector3 bridge = HexMetrics.GetWaterBridge(direction); … HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { waterShore.AddTriangle( e1.v5, e2.v5, e1.v5 + HexMetrics.GetWaterBridge(direction.Next()) ); … } }

Длинные мосты в воде.

Между рёбрами воды и суши

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

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

// Vector3 bridge = HexMetrics.GetWaterBridge(direction); Vector3 center2 = neighbor.Position; center2.y = center.y; EdgeVertices e2 = new EdgeVertices( center2 + HexMetrics.GetSecondSolidCorner(direction.Opposite()), center2 + HexMetrics.GetFirstSolidCorner(direction.Opposite()) ); … HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; center3.y = center.y; waterShore.AddTriangle( e1.v5, e2.v5, center3 + HexMetrics.GetFirstSolidCorner(direction.Previous()) ); … }

Неправильные углы рёбер.

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

HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (nextNeighbor != null) {
// Vector3 center3 = nextNeighbor.Position;
// center3.y = center.y; Vector3 v3 = nextNeighbor.Position + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); v3.y = center.y; waterShore.AddTriangle(e1.v5, e2.v5, v3); waterShore.AddTriangleUV( new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, nextNeighbor.IsUnderwater ? 0f : 1f) ); }

Правильные углы рёбер.

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

shore = sqrt(shore) * 0.9;

Готовая пена.

unitypackage

Подводные реки

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

Реки, текущие в воде.

Ближайшие объекты рендерятся последними, благодаря чему оказываются наверху. Порядок, в котором рендерятся полупрозрачные объекты, зависит от их расстояния до камеры. Давайте начнём с того, что сделаем порядок рендеринга постоянным. При перемещении камеры это будет означать, что иногда реки, а иногда вода будут появляться поверх друг друга. Мы можем реализовать это, изменив очередь шейдера River. Реки должны отрисовываться поверх воды, чтобы правильно отображались водопады.

Tags { "RenderType"="Transparent" "Queue"="Transparent+1" }

Отрисовываем реки последними.

Прячем подводные реки

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

void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater) { bool reversed = cell.HasIncomingRiver; … } } void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater) { bool reversed = cell.IncomingRiver == direction; … } }

Для TriangulateConnection начнём того, что будем добавлять сегмент реки, когда ни текущая, ни соседняя ячейка не находятся под водой.

if (cell.HasRiverThroughEdge(direction)) { e2.v3.y = neighbor.StreamBedY; if (!cell.IsUnderwater && !neighbor.IsUnderwater) { TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction ); } }

Больше никаких подводных рек.

Водопады

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

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

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

void TriangulateWaterfallInWater ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float y1, float y2, float waterY ) { v1.y = v2.y = y1; v3.y = v4.y = y2; rivers.AddQuad(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0.8f, 1f); }

Вызовем этот метод в TriangulateConnection, когда сосед оказывается под водой и у нас создаётся водопад.

if (!cell.IsUnderwater) { if (!neighbor.IsUnderwater) { TriangulateRiverQuad( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f, cell.HasIncomingRiver && cell.IncomingRiver == direction ); } else if (cell.Elevation > neighbor.WaterLevel) { TriangulateWaterfallInWater( e1.v2, e1.v4, e2.v2, e2.v4, cell.RiverSurfaceY, neighbor.RiverSurfaceY, neighbor.WaterSurfaceY ); } }

Также нам нужно обрабатывать водопады в противоположном направлении, когда текущая ячейка под водой, а соседняя — нет.

if (!cell.IsUnderwater) { … } else if ( !neighbor.IsUnderwater && neighbor.Elevation > cell.WaterLevel ) { TriangulateWaterfallInWater( e2.v4, e2.v2, e1.v4, e1.v2, neighbor.RiverSurfaceY, cell.RiverSurfaceY, cell.WaterSurfaceY ); }

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

Интерполируем.

Это даст нам значение интерполятора. Чтобы переместить нижние вершины вверх, разделим их расстояние ниже поверхности воды на высоту водопада.

v1.y = v2.y = y1; v3.y = v4.y = y2; float t = (waterY - y2) / (y1 - y2); v3 = Vector3.Lerp(v3, v1, t); v4 = Vector3.Lerp(v4, v2, t); rivers.AddQuad(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0.8f, 1f);

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

v1.y = v2.y = y1; v3.y = v4.y = y2; v1 = HexMetrics.Perturb(v1); v2 = HexMetrics.Perturb(v2); v3 = HexMetrics.Perturb(v3); v4 = HexMetrics.Perturb(v4); float t = (waterY - y2) / (y1 - y2); v3 = Vector3.Lerp(v3, v1, t); v4 = Vector3.Lerp(v4, v2, t); rivers.AddQuadUnperturbed(v1, v2, v3, v4); rivers.AddQuadUV(0f, 1f, 0.8f, 1f);

Так как у нас уже есть метод для добавления неискажённых треугольников, нам на самом деле не нужно создавать его для quad-ов. Поэтому добавим необходимый метод HexMesh.AddQuadUnperturbed.

public void AddQuadUnperturbed ( Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4 ) { int vertexIndex = vertices.Count; vertices.Add(v1); vertices.Add(v2); vertices.Add(v3); vertices.Add(v4); triangles.Add(vertexIndex); triangles.Add(vertexIndex + 2); triangles.Add(vertexIndex + 1); triangles.Add(vertexIndex + 1); triangles.Add(vertexIndex + 2); triangles.Add(vertexIndex + 3); }

Водопады заканчиваются на поверхности воды.

unitypackage

Устья

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

Река встречается с побережьем без искажения вершин.

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

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

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

void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary(e1, e2); } else { waterShore.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); waterShore.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); waterShore.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); waterShore.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); waterShore.AddQuadUV(0f, 0f, 0f, 1f); } … } void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) { }

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

void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) { waterShore.AddTriangle(e2.v1, e1.v2, e1.v1); waterShore.AddTriangle(e2.v5, e1.v5, e1.v4); waterShore.AddTriangleUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); waterShore.AddTriangleUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f) ); }

Трапецоидная дыра для области смешения.

UV2-координаты

Для создания эффекта реки нам нужны UV-координаты. Но для создания эффекта пены тоже нужны UV-координаты. То есть при их смешивании нам потребуется два набора UV-координат. К счастью, меши движка Unity могут поддерживать до четырёх наборов UV. Нам просто нужно добавить в HexMesh поддержку второго набора.

public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; [NonSerialized] List<Vector2> uvs, uv2s; public void Clear () { … if (useUVCoordinates) { uvs = ListPool<Vector2>.Get(); } if (useUV2Coordinates) { uv2s = ListPool<Vector2>.Get(); } triangles = ListPool<int>.Get(); } public void Apply () { … if (useUVCoordinates) { hexMesh.SetUVs(0, uvs); ListPool<Vector2>.Add(uvs); } if (useUV2Coordinates) { hexMesh.SetUVs(1, uv2s); ListPool<Vector2>.Add(uv2s); } … }

Чтобы добавить второй набор UV, мы дублируем методы работы с UV и изменим нужным нам образом.

public void AddTriangleUV2 (Vector2 uv1, Vector2 uv2, Vector3 uv3) { uv2s.Add(uv1); uv2s.Add(uv2); uv2s.Add(uv3); } public void AddQuadUV2 (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) { uv2s.Add(uv1); uv2s.Add(uv2); uv2s.Add(uv3); uv2s.Add(uv4); } public void AddQuadUV2 (float uMin, float uMax, float vMin, float vMax) { uv2s.Add(new Vector2(uMin, vMin)); uv2s.Add(new Vector2(uMax, vMin)); uv2s.Add(new Vector2(uMin, vMax)); uv2s.Add(new Vector2(uMax, vMax)); }

Функция шейдера реки

Так как мы будем использовать эффект реки в двух шейдерах, переместим код из шейдера River в новую функцию include-файла Water.

float River (float2 riverUV, sampler2D noiseTex) { float2 uv = riverUV; uv.x = uv.x * 0.0625 + _Time.y * 0.005; uv.y -= _Time.y * 0.25; float4 noise = tex2D(noiseTex, uv); float2 uv2 = riverUV; uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052; uv2.y -= _Time.y * 0.23; float4 noise2 = tex2D(noiseTex, uv2); return noise.x * noise2.w;
}

Изменим шейдер River так, чтобы использовать эту новую функцию.

#include "Water.cginc" sampler2D _MainTex; … void surf (Input IN, inout SurfaceOutputStandard o) { float river = River(IN.uv_MainTex, _MainTex); fixed4 c = saturate(_Color + river); … }

Объекты устья

Добавим в HexGridChunk поддержку объекта меша устья.

public HexMesh terrain, rivers, roads, water, waterShore, estuaries; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); water.Clear(); waterShore.Clear(); estuaries.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); water.Apply(); waterShore.Apply(); estuaries.Apply(); }

Создадим шейдер, материал и объект устья, продублировав побережье и изменив его. Соединим его с фрагментом, и сделаем так, чтобы он использовал координаты UV и UV2.

Объект Estuarties.

Триангуляция устья

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

void TriangulateEstuary (EdgeVertices e1, EdgeVertices e2) { … estuaries.AddTriangle(e1.v3, e2.v2, e2.v4); estuaries.AddTriangleUV( new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 1f) ); }

Средний треугольник.

Мы можем заполнить весь трапецоид, добавив quad с обеих сторон от среднего треугольника.

estuaries.AddQuad(e1.v2, e1.v3, e2.v1, e2.v2); estuaries.AddTriangle(e1.v3, e2.v2, e2.v4); estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5); estuaries.AddQuadUV(0f, 0f, 0f, 1f); estuaries.AddTriangleUV( new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 1f) ); estuaries.AddQuadUV(0f, 0f, 0f, 1f);

Готовый трапецоид.

Давайте повернём ориентацию quad-а влево, чтобы у него было укороченное диагональное соединение, и в результате получим симметричную геометрию.

estuaries.AddQuad(e2.v1, e1.v2, e2.v2, e1.v3); estuaries.AddTriangle(e1.v3, e2.v2, e2.v4); estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5); estuaries.AddQuadUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 1f), new Vector2(0f, 0f) );
// estuaries.AddQuadUV(0f, 0f, 0f, 1f);

Повёрнутый quad, симметричная геометрия

Течение реки

Для поддержки эффекта реки нам нужно добавить UV2-координаты. Низ среднего треугольника находится в середине реки, поэтому его координата U должна быть равна 0.5. Так как река течёт по направлению к воде, лева точка получает координату U, равную 1, а правая — координату U со значением 0. Зададим координатам Y значения 0 и 1, соответствующие направлению течения.

estuaries.AddTriangleUV2( new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) );

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

estuaries.AddQuadUV2( new Vector2(1f, 0f), new Vector2(1f, 1f), new Vector2(1f, 0f), new Vector2(0.5f, 1f) ); estuaries.AddTriangleUV2( new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f) ); estuaries.AddQuadUV2( new Vector2(0.5f, 1f), new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f) );

UV2 трапецоида.

Мы можем получить доступ к этим координатам, добавив ко входной структуре float2 uv2_MainTex. Чтобы убедиться, что мы задали UV2-координаты верно, заставим шейдер Estuary их визуализировать.

struct Input { float2 uv_MainTex; float2 uv2_MainTex; float3 worldPos; }; … void surf (Input IN, inout SurfaceOutputStandard o) { float shore = IN.uv_MainTex.y; float foam = Foam(shore, IN.worldPos.xz, _MainTex); float waves = Waves(IN.worldPos.xz, _MainTex); waves *= 1 - shore; fixed4 c = fixed4(IN.uv2_MainTex, 1, 1); … }

UV2-координаты.

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

void surf (Input IN, inout SurfaceOutputStandard o) { … float river = River(IN.uv2_MainTex, _MainTex); fixed4 c = saturate(_Color + river); … }

Используем UV2 для создания эффекта реки.

8 до 1. Мы создали реки таким образом, что при триангуляции соединений между ячейками координаты V реки изменяются от 0. Однако соединение побережья на 50% больше, чем обычные соединения ячеек. Поэтому здесь мы тоже должны использовать этот интервал, а не значения от 0 до 1. 8 до 1. Поэтому для наилучшего соответствия с течением реки мы должны изменять значения от 0. 1.

estuaries.AddQuadUV2( new Vector2(1f, 0.8f), new Vector2(1f, 1.1f), new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f) ); estuaries.AddTriangleUV2( new Vector2(0.5f, 1.1f), new Vector2(1f, 0.8f), new Vector2(0f, 0.8f) ); estuaries.AddQuadUV2( new Vector2(0.5f, 1.1f), new Vector2(0f, 1.1f), new Vector2(0f, 0.8f), new Vector2(0f, 0.8f) );

Синхронизированное течение реки и устья.

Настройка течения

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

5. Вместо того, чтобы сохранять верхние координаты U постоянными за пределами ширины реки, сдвинем их на 0. 5, самая правая — значение −0. Самая левая точка принимает значение 1. 5.

Изменим левую с 1 на 0. В то же время, расширим течение, сдвинув координаты U левой и правой точек низа. 3. 7, а правую — с 0 на 0.

estuaries.AddQuadUV2( new Vector2(1.5f, 0.8f), new Vector2(0.7f, 1.1f), new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f) ); … estuaries.AddQuadUV2( new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.1f), new Vector2(0f, 0.8f), new Vector2(-0.5f, 0.8f) );

Расширение течения реки.

Так как вода течёт в сторону от конца реки, увеличим координаты V верхних точек до 1. Чтобы завершить эффект искривления, изменим координаты V тех же четырёх точек. 15. И чтобы создать более качественную кривую, увеличим координаты V двух нижних точек до 1.

estuaries.AddQuadUV2( new Vector2(1.5f, 1f), new Vector2(0.7f, 1.15f), new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f) ); estuaries.AddTriangleUV2( new Vector2(0.5f, 1.1f), new Vector2(1f, 0.8f), new Vector2(0f, 0.8f) ); estuaries.AddQuadUV2( new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.15f), new Vector2(0f, 0.8f), new Vector2(-0.5f, 1f) );

Искривлённое течение реки.

Смешение реки и побережья

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

float shoreWater = max(foam, waves); float river = River(IN.uv2_MainTex, _MainTex); float water = lerp(shoreWater, river, IN.uv_MainTex.x); fixed4 c = saturate(_Color + water);

Хотя это и должно работать, вы можете получить ошибку компиляции. Компилятор жалуется на переопределение _MainTex_ST. Причина заключается в ошибке внутри компилятора поверхностных шейдеров Unity, вызванной одновременным использованием uv_MainTex и uv2_MainTex. Нам нужно найти обходной путь.

Для этого переименуем uv2_MainTex в riverUV. Вместо использования uv2_MainTex нам придётся передавать вторичные UV-координаты вручную. Затем добавим в шейдер вершинную функцию, которая назначает ему координаты.

#pragma surface surf Standard alpha vertex:vert … struct Input { float2 uv_MainTex; float2 riverUV; float3 worldPos; }; … void vert (inout appdata_full v, out Input o) { UNITY_INITIALIZE_OUTPUT(Input, o); o.riverUV = v.texcoord1.xy; } void surf (Input IN, inout SurfaceOutputStandard o) { … float river = River(IN.riverUV, _MainTex); … }

Интерполяция на основании значения побережья.

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

estuaries.AddQuadUV( new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0f, 0f) ); estuaries.AddTriangleUV( new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(1f, 1f) ); estuaries.AddQuadUV( new Vector2(0f, 0f), new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(0f, 1f) );
// estuaries.AddQuadUV(0f, 0f, 0f, 1f);

Правильное смешение.

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

Устья в действии

unitypackage

Реки, вытекающие из водоёмов

У нас уже есть реки, втекающие в водоёмы, но нет поддержки рек, текущих в другом направлении. Есть озёра, из которых реки вытекают, поэтому нам нужно их тоже добавить.

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

bool IsValidRiverDestination (HexCell neighbor) { return neighbor && ( elevation >= neighbor.elevation || waterLevel == neighbor.elevation ); }

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

public void SetOutgoingRiver (HexDirection direction) { if (hasOutgoingRiver && outgoingRiver == direction) { return; } HexCell neighbor = GetNeighbor(direction);
// if (!neighbor || elevation < neighbor.elevation) { if (!IsValidRiverDestination(neighbor)) { return; } RemoveOutgoingRiver(); … }

Также там нужно проверять реки при изменении высоты ячейки или уровня воды. Создадим частный метод, который займётся этой задачей.

void ValidateRivers () { if ( hasOutgoingRiver && !IsValidRiverDestination(GetNeighbor(outgoingRiver)) ) { RemoveOutgoingRiver(); } if ( hasIncomingRiver && !GetNeighbor(incomingRiver).IsValidRiverDestination(this) ) { RemoveIncomingRiver(); } }

Воспользуемся этим новым методом в свойствах Elevation и WaterLevel.

public int Elevation { … set { … // if (
// hasOutgoingRiver &&
// elevation < GetNeighbor(outgoingRiver).elevation
// ) {
// RemoveOutgoingRiver();
// }
// if (
// hasIncomingRiver &&
// elevation > GetNeighbor(incomingRiver).elevation
// ) {
// RemoveIncomingRiver();
// } ValidateRivers(); … } } public int WaterLevel { … set { if (waterLevel == value) { return; } waterLevel = value; ValidateRivers(); Refresh(); } }

Исходящие и входящие в озёра реки.

Разворачиваем течение

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

void TriangulateEstuary ( EdgeVertices e1, EdgeVertices e2, bool incomingRiver ) { …
}

Будем передавать эту информацию при вызове этого метода из TriangulateWaterShore.

if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary(e1, e2, cell.IncomingRiver == direction); }

Теперь нам нужно развернуть течение реки, изменив координаты UV2. Координаты U для исходящих рек нужно отзеркалить: −0.5 становится 1.5, 0 становится 1, 1 становится 0, а 1.5 становится −0.5.

Если посмотреть на то, как мы работали с перевёрнутыми соединениями рек, то 0. С координатами V всё немного сложнее. 2. 8 должно стать 0, а 1 должна стать −0. 1 становится −0. Это значит, что 1. 15 становится −0. 3, а 1. 35.

Так как в каждом случае координаты UV2 очень отличаются, давайте напишем для них отдельный код.

void TriangulateEstuary ( EdgeVertices e1, EdgeVertices e2, bool incomingRiver ) { … if (incomingRiver) { estuaries.AddQuadUV2( new Vector2(1.5f, 1f), new Vector2(0.7f, 1.15f), new Vector2(1f, 0.8f), new Vector2(0.5f, 1.1f) ); estuaries.AddTriangleUV2( new Vector2(0.5f, 1.1f), new Vector2(1f, 0.8f), new Vector2(0f, 0.8f) ); estuaries.AddQuadUV2( new Vector2(0.5f, 1.1f), new Vector2(0.3f, 1.15f), new Vector2(0f, 0.8f), new Vector2(-0.5f, 1f) ); } else { estuaries.AddQuadUV2( new Vector2(-0.5f, -0.2f), new Vector2(0.3f, -0.35f), new Vector2(0f, 0f), new Vector2(0.5f, -0.3f) ); estuaries.AddTriangleUV2( new Vector2(0.5f, -0.3f), new Vector2(0f, 0f), new Vector2(1f, 0f) ); estuaries.AddQuadUV2( new Vector2(0.5f, -0.3f), new Vector2(0.7f, -0.35f), new Vector2(1f, 0f), new Vector2(1.5f, -0.2f) ); } }

Правильное течение рек.

unitypackage

  • Добавляем на рельеф объекты.
  • Создаём поддержку уровней плотности объектов.
  • Используем в уровне различные объекты.
  • Смешиваем три разных типа объектов.

В этой части мы поговорим о добавлении на рельеф объектов. Мы создадим такие объекты, как здания и деревья.

Конфликт между лесами, сельскохозяйственными землями и урбанизацией.

Добавляем поддержку объектов

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

Он просто приказывает одному из своих дочерних элементов HexMesh добавить треугольник или quad. HexGridChunk не волнует, как работает меш. Аналогично, у него может быть дочерний элемент, занимающийся размещением на них объектов.

Менеджер объектов

Давайте создадим компонент HexFeatureManager, который займётся объектами в пределах одного фрагмента. Воспользуемся той же схемой, что и в HexMesh — дадим ему методы Clear, Apply и AddFeature. Так как объект нужно где-то размещать, метод AddFeature получает параметр позиции.

Мы начнём с реализации-заготовки, которая пока ничего не будет делать.

using UnityEngine; public class HexFeatureManager : MonoBehaviour { public void Clear () {} public void Apply () {} public void AddFeature (Vector3 position) {}
}

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

public HexFeatureManager features; public void Triangulate () { terrain.Clear(); rivers.Clear(); roads.Clear(); water.Clear(); waterShore.Clear(); estuaries.Clear(); features.Clear(); for (int i = 0; i < cells.Length; i++) { Triangulate(cells[i]); } terrain.Apply(); rivers.Apply(); roads.Apply(); water.Apply(); waterShore.Apply(); estuaries.Apply(); features.Apply(); }

Давайте начнём с размещения одного объекта в центре каждой ячейки

void Triangulate (HexCell cell) { for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { Triangulate(d, cell); } features.AddFeature(cell.Position); }

Теперь нам понадобится настоящий менеджер объектов. Добавим в префаб Hex Grid Chunk ещё один дочерний объект и дадим ему компонент HexFeatureManager. Затем можно соединить с ним фрагмент.

Менеджер объектов, добавленный в префаб фрагмента.

Префаб объектов

Какой объект рельефа мы создадим? Для первого теста вполне подойдёт куб. Создадим достаточно большой куб, допустим, с масштабом (3, 3, 3), и превратим его в префаб. Также создадим для него материал. Я использовал материал по умолчанию с красным цветом. Удалим его коллайдер, потому что он нам не понадобится.

Префаб куба.

Так как для размещения объекта необходим доступ к компоненту transform, используем его в качестве типа ссылки. Менеджерам объектов потребуется ссылка на этот префаб, поэтому добавим её в HexFeatureManager, а затем подключим их.

public Transform featurePrefab;

Менеджер объектов с префабом.

Создание экземпляров объектов

Структура готова, и мы можем приступить к добавлению объектов рельефа! Достаточно просто создать экземпляр префаба в HexFeatureManager.AddFeature и задать его позицию.

public void AddFeature (Vector3 position) { Transform instance = Instantiate(featurePrefab); instance.localPosition = position; }

Экземпляры объектов рельефа.

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

public void AddFeature (Vector3 position) { Transform instance = Instantiate(featurePrefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = position; }

Кубы на поверхности рельефа.

Что, если мы будем использовать другой меш?

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

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

instance.localPosition = HexMetrics.Perturb(position);

Искажённые позиции объектов.

Уничтожение объектов рельефа

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

Тогда при вызове Clear мы будем уничтожать этот контейнер и создавать новый. Быстрее всего это сделать, создав игровой объект-контейнер и превратив все объекты рельефа в его дочерние элементы. Сам контейнер будет дочерним элементом своего менеджера.

Transform container; public void Clear () { if (container) { Destroy(container.gameObject); } container = new GameObject("Features Container").transform; container.SetParent(transform, false); } … public void AddFeature (Vector3 position) { Transform instance = Instantiate(featurePrefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.SetParent(container, false); }

Наверно, неэффективно каждый раз создавать и уничтожать объекты рельефа.

Но пока нас это волновать не должно. Да, кажется, что это так. Разобравшись с этим, мы увидим, что такие действия являются узким местом, поэтому подумаем об эффективности. Сначала нам нужно правильно разместить объекты. Apply. Именно тогда мы можем прийти и к использованию метода HexFeatureManager. К счастью, всё не так плохо, потому что мы разделили рельеф на фрагменты. Но оставим это для будущего туториала.

unitypackage

Размещение объектов рельефа

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

Объекты расположены повсюду.

Triangulate, является ли ячейка пустой. Поэтому давайте перед размещением объекта проверять в HexGridChunk.

if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) { features.AddFeature(cell.Position); }

Ограниченное размещение.

По одному объекту на направление

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

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

void Triangulate (HexDirection direction, HexCell cell) { … if (cell.HasRiver) { … } else { TriangulateWithoutRiver(direction, cell, center, e); if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) { features.AddFeature((center + e.v1 + e.v5) * (1f / 3f)); } } … }

Много объектов, но нет по соседству с реками.

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

void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) { features.AddFeature((center + e.v1 + e.v5) * (1f / 3f)); } }

Объекты появились рядом с реками.

Можно ли рендерить такое количество объектов?

Так как объекты малы, их меши должны иметь всего несколько вершин. Большое количество объектов создаёт множество вызовов отрисовки, но здесь помогает dynamic batching движка Unity. Но если это окажется «узким местом», то придётся поработать с ними в будущем. Это позволяет объединить многие из них в один batch. Также можно использовать instancing, который при работе со множеством мелких мешей сравним с dynamic batching.

unitypackage

Разнообразие объектов

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

public void AddFeature (Vector3 position) { Transform instance = Instantiate(featurePrefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.localRotation = Quaternion.Euler(0f, 360f * Random.value, 0f); instance.SetParent(container, false); }

Случайные повороты.

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

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

Создание таблицы хешей

Мы можем создать таблицу хешей из массива значений float и заполнить её один раз случайными значениями. Благодаря этому текстура нам вообще не понадобится. Давайте добавим её в HexMetrics. Размера 256 на 256 хватит для достаточной вариативности.

public const int hashGridSize = 256; static float[] hashGrid; public static void InitializeHashGrid () { hashGrid = new float[hashGridSize * hashGridSize]; for (int i = 0; i < hashGrid.Length; i++) { hashGrid[i] = Random.value; } }

Случайные значения генерируются по математической формуле, всегда дающей одинаковые результаты. Получаемая последовательность зависит от числа seed, которое по умолчанию равно текущему значению времени. Именно поэтому в каждой игровой сессии мы будем получать разные результаты.

Чтобы обеспечить воссоздание всегда одинаковых объектов, нам нужно добавить параметр seed в метод инициализации.

public static void InitializeHashGrid (int seed) { hashGrid = new float[hashGridSize * hashGridSize]; Random.InitState(seed); for (int i = 0; i < hashGrid.Length; i++) { hashGrid[i] = Random.value; } }

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

Random.State currentState = Random.state; Random.InitState(seed); for (int i = 0; i < hashGrid.Length; i++) { hashGrid[i] = Random.value; } Random.state = currentState;

Инициализация таблицы хешей выполняется в HexGrid в то же самое время, когда он назначает текстуру шума. То есть в методах HexGrid.Start и HexGrid.Awake. Сделаем, так чтобы значения генерировались не чаще, чем это нужно.

public int seed; void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); … } void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); } }

Общая переменная seed позволяет нам выбирать значение seed для карты. Подойдёт любое значение. Я выбрал 1234.

Выбор seed.

Использование таблицы хешей

Чтобы воспользоваться таблицей хешей, добавим в HexMetrics сэмплирующий метод. Как и SampleNoise, он использует для получения значения координаты XZ позиции. Индекс хеша находится ограничением координат до целочисленных значений, а потом получением остатка от целочисленного деления на размер таблицы.

public static float SampleHashGrid (Vector3 position) { int x = (int)position.x % hashGridSize; int z = (int)position.z % hashGridSize; return hashGrid[x + z * hashGridSize]; }

Что делает %?

Например, ряд −4, −3, −2, −1, 0, 1, 2, 3, 4 modulo 3 превращается в −1, 0, −2, −1, 0, 1, 2, 0, 1. Это оператор модуля, он вычисляет остаток от деления, в нашем случае — целочисленного деления.

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

int x = (int)position.x % hashGridSize; if (x < 0) { x += hashGridSize; } int z = (int)position.z % hashGridSize; if (z < 0) { z += hashGridSize; }

Теперь для каждой квадратной единицы мы создаём своё значение. Однако на самом деле нам не нужна такая плотность таблицы. Объекты разнесены друг от друга дальше. Мы можем растянуть таблицу, уменьшив масштаб позиции перед вычислением индекса. Нам будет достаточно одного уникального значения для квадрата 4 на 4.

public const float hashGridScale = 0.25f; public static float SampleHashGrid (Vector3 position) { int x = (int)(position.x * hashGridScale) % hashGridSize; if (x < 0) { x += hashGridSize; } int z = (int)(position.z * hashGridScale) % hashGridSize; if (z < 0) { z += hashGridSize; } return hashGrid[x + z * hashGridSize]; }

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

public void AddFeature (Vector3 position) { float hash = HexMetrics.SampleHashGrid(position); Transform instance = Instantiate(featurePrefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.localRotation = Quaternion.Euler(0f, 360f * hash, 0f); instance.SetParent(container, false); }

Порог размещения

Хотя объекты имеют разный поворот, в их размещении всё равно заметен паттерн. В каждой ячейке есть по семь объектов. Мы можем добавить в эту схему хаос, произвольным образом пропуская некоторые из объектов. Как нам решать, добавлять ли объект, или нет? Разумеется, проверяя ещё одно случайное значение!

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

using UnityEngine; public struct HexHash { public float a, b; public static HexHash Create () { HexHash hash; hash.a = Random.value; hash.b = Random.value; return hash; }
}

Разве её не нужно сериализовать?

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

Изменим HexMetrics так, чтобы он использовал новую структуру.

static HexHash[] hashGrid; public static void InitializeHashGrid (int seed) { hashGrid = new HexHash[hashGridSize * hashGridSize]; Random.State currentState = Random.state; Random.InitState(seed); for (int i = 0; i < hashGrid.Length; i++) { hashGrid[i] = HexHash.Create(); } Random.state = currentState; } public static HexHash SampleHashGrid (Vector3 position) { … }

Теперь HexFeatureManager.AddFeature имеет доступ к двум значениям хешей. Давайте используем первое, чтобы решать, добавлять ли объект, или пропустить его. Если значение равно или больше 0.5, то пропускаем. При этом мы избавимся примерно от половины объектов. Второе значение как обычно будет использоваться для определения поворота.

public void AddFeature (Vector3 position) { HexHash hash = HexMetrics.SampleHashGrid(position); if (hash.a >= 0.5f) { return; } Transform instance = Instantiate(featurePrefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.localRotation = Quaternion.Euler(0f, 360f * hash.b, 0f); instance.SetParent(container, false); }

Плотность объектов снижена на 50%.

unitypackage

Рисование объектов

Вместо размещения объектов повсюду, давайте сделаем их редактируемыми. Но мы не будем рисовать отдельные объекты, а добавим к каждой ячейке уровень объектов. Этот уровень будет управлять вероятностью появления в ячейке объектов. По умолчанию значение равно нулю, то есть объекты будут отсутствовать.

Они будут представлять урбанизацию. Так как красные кубы выглядят на нашем рельефе не как природные объекты, давайте назовём их зданиями. Давайте добавим в HexCell уровень урбанизации.

public int UrbanLevel { get { return urbanLevel; } set { if (urbanLevel != value) { urbanLevel = value; RefreshSelfOnly(); } } } int urbanLevel;

Мы можем сделать так, чтобы уровень урбанизации для подводной ячейки был равен нулю, но это необязательно, мы и так пропускаем создание подводных объектов. И возможно, на каком-то этапе мы добавим водные объекты урбанизации, такие как доки и подводные структуры.

Ползунок плотности

Для изменения уровня урбанизации добавим в HexMapEditor поддержку ещё одного ползунка.

int activeUrbanLevel; … bool applyUrbanLevel; … public void SetApplyUrbanLevel (bool toggle) { applyUrbanLevel = toggle; } public void SetUrbanLevel (float level) { activeUrbanLevel = (int)level; } void EditCell (HexCell cell) { if (cell) { … if (applyWaterLevel) { cell.WaterLevel = activeWaterLevel; } if (applyUrbanLevel) { cell.UrbanLevel = activeUrbanLevel; } if (riverMode == OptionalToggle.No) { cell.RemoveRiver(); } … } }

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

Давайте остановимся на четырёх, обозначающих нулевой, низкий, средний и высокий уровень плотности. Сколько уровней нам потребуется?

Ползунок урбанизации.

Изменение порога

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

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

public void AddFeature (HexCell cell, Vector3 position) { HexHash hash = HexMetrics.SampleHashGrid(position); if (hash.a >= cell.UrbanLevel * 0.25f) { return; } … }

Чтобы это заработало, передадим ячейки в HexGridChunk.

void Triangulate (HexCell cell) { … if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) { features.AddFeature(cell, cell.Position); } } void Triangulate (HexDirection direction, HexCell cell) { … if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) { features.AddFeature(cell, (center + e.v1 + e.v5) * (1f / 3f)); } … } … void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … if (!cell.IsUnderwater && !cell.HasRoadThroughEdge(direction)) { features.AddFeature(cell, (center + e.v1 + e.v5) * (1f / 3f)); } }

Рисование уровней плотности урбанизации.

unitypackage

Несколько префабов объектов рельефа

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

Для получения соответствующего префаба будем вычитать из уровня урбанизации единицу и использовать значение как индекс. Избавимся от поля featurePrefab в HexFeatureManager и заменим его на массив для префабов урбанизации.

<del>// public Transform featurePrefab;</del> public Transform[] urbanPrefabs; public void AddFeature (HexCell cell, Vector3 position) { … Transform instance = Instantiate(urbanPrefabs[cell.UrbanLevel - 1]); … }

Создадим два дубликата префаба объекта, переименуем и изменим их так, чтобы они обозначали три разных уровня урбанизации. Уровень 1 — это низкая плотность, поэтому используем куб с единичной длиной ребра, обозначающий лачугу. Я изменю масштаб префаба уровня 2 на (1.5, 2, 1.5), чтобы это походило на двухэтажное здание. Для высоких зданий уровня 3 я использовал масштаб (2, 5, 2).

Использование разных префабов для каждого уровня урбанизации.

Смешение префабов

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

Других зданий тут не будет вообще. На уровне 1 используем размещения лачуг в 40% случаев. 4, 0, 0). Для уровня используем тройку значений (0.

Высоких зданий делать не будем. На уровне 2 заменим лачуги зданиями побольше, и добавим вероятность 20% для дополнительных лачуг. 2, 0. То есть используем пороговую тройку значений (0. 4, 0).

Пороговые значения будут равны (0. На уровне 3 заменим средние здания на высокие, снова заменим лачуги и добавим ещё одну вероятность 20% лачуг. 2, 0. 2, 0. 4).

Для удаления существующего здания нам нужно использовать те же интервалы значений хешей. То есть идея заключается в том, что при повышении уровня урбанизации мы будем апгрейдить существующие здания и добавлять новые в пустые места. 4 на уровне 1 были лачугами, то на уровне 3 тот же интервал будет создавать высокие здания. Если хеши между 0 и 0. 4, двухэтажные здания — в интервале 0. На уровне 3 высокие здания должны создаваться при значениях хешей в интервале 0–0. 6, а лачуги — в интервале 0. 4–0. 8. 6–0. 4, 0. Если проверять их с наибольших до наименьших, то это можно сделать с помощью тройки порогов (0. 8). 6, 0. 4, 0. Пороги уровня 2 тогда станут (0, 0. 4). 6), а пороги уровня 1 станут (0, 0, 0.

Так как нас интересуют уровни только с объектами, мы игнорируем уровень 0. Давайте сохраним эти пороги в HexMetrics как коллекцию массивов с методом, позволяющим получать пороги для определённого уровня.

static float[][] featureThresholds = { new float[] {0.0f, 0.0f, 0.4f}, new float[] {0.0f, 0.4f, 0.6f}, new float[] {0.4f, 0.6f, 0.8f} }; public static float[] GetFeatureThresholds (int level) { return featureThresholds[level]; }

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

Transform PickPrefab (int level, float hash) { if (level > 0) { float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1); for (int i = 0; i < thresholds.Length; i++) { if (hash < thresholds[i]) { return urbanPrefabs[i]; } } } return null; }

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

Перевёрнутый порядок префабов.

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

public void AddFeature (HexCell cell, Vector3 position) { HexHash hash = HexMetrics.SampleHashGrid(position);
// if (hash.a >= cell.UrbanLevel * 0.25f) {
// return;
// }
// Transform instance = Instantiate(urbanPrefabs[cell.UrbanLevel - 1]); Transform prefab = PickPrefab(cell.UrbanLevel, hash.a); if (!prefab) { return; } Transform instance = Instantiate(prefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.localRotation = Quaternion.Euler(0f, 360f * hash.b, 0f); instance.SetParent(container, false); }

Смешиваем префабы.

Вариации на уровне

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

public float a, b, c; public static HexHash Create () { HexHash hash; hash.a = Random.value; hash.b = Random.value; hash.c = Random.value; return hash; }

Превратим HexFeatureManager.urbanPrefabs в массив массивов, и добавим к методу PickPrefab параметр choice. Используем его для выбора индекса встроенного массива, умножив его на длину этого массива и преобразовав в integer.

public Transform[][] urbanPrefabs; … Transform PickPrefab (int level, float hash, float choice) { if (level > 0) { float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1); for (int i = 0; i < thresholds.Length; i++) { if (hash < thresholds[i]) { return urbanPrefabs[i][(int)(choice * urbanPrefabs[i].Length)]; } } } return null; }

Давайте обоснуем наш выбор на значении второго хеша (B). Тогда потребуется, чтобы поворот сменился с B на C.

public void AddFeature (HexCell cell, Vector3 position) { HexHash hash = HexMetrics.SampleHashGrid(position); Transform prefab = PickPrefab(cell.UrbanLevel, hash.a, hash.b); if (!prefab) { return; } Transform instance = Instantiate(prefab); position.y += instance.localScale.y * 0.5f; instance.localPosition = HexMetrics.Perturb(position); instance.localRotation = Quaternion.Euler(0f, 360f * hash.c, 0f); instance.SetParent(container, false); }

Прежде чем продолжить, нам нужно учесть, что Random.value может выдать значение 1. Из-за этого индекс массива может выйти за пределы. Чтобы этого не случилось, немного изменим масштаб значений хешей. Просто отмасштабируем их все, чтобы не беспокоиться об конкретном, используемом нами.

public static HexHash Create () { HexHash hash; hash.a = Random.value * 0.999f; hash.b = Random.value * 0.999f; hash.c = Random.value * 0.999f; return hash; }

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

using UnityEngine; [System.Serializable] public struct HexFeatureCollection { public Transform[] prefabs; public Transform Pick (float choice) { return prefabs[(int)(choice * prefabs.Length)]; }
}

Используем в HexFeatureManager вместо встроенных массивов массив таких коллекций.

// public Transform[][] urbanPrefabs; public HexFeatureCollection[] urbanCollections; … Transform PickPrefab (int level, float hash, float choice) { if (level > 0) { float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1); for (int i = 0; i < thresholds.Length; i++) { if (hash < thresholds[i]) { return urbanCollections[i].Pick(choice); } } } return null; }

Теперь мы можем назначать каждому уровню плотности несколько зданий. Так как они независимы, нам не обязательно использовать одинаковое количество на уровень. Я просто использовал по два варианта на уровень, добавив к каждому более длинный нижний вариант. Я выбрал для них масштабы (3.5, 3, 2), (2.75, 1.5, 1.5) и (1.75, 1, 1).

По два типа зданий на уровень плотности.

unitypackage

Несколько типов объектов

В имеющейся схеме мы можем создавать вполне достойные урбанистические структуры. Но рельеф может содержать не только здания. Как насчёт ферм или растений? Давайте добавим в HexCell уровни и для них. Они не исключают друг друга и могут смешиваться.

public int FarmLevel { get { return farmLevel; } set { if (farmLevel != value) { farmLevel = value; RefreshSelfOnly(); } } } public int PlantLevel { get { return plantLevel; } set { if (plantLevel != value) { plantLevel = value; RefreshSelfOnly(); } } } int urbanLevel, farmLevel, plantLevel;

Разумеется, для этого требуется поддержка в HexMapEditor двух дополнительных ползунков.

int activeUrbanLevel, activeFarmLevel, activePlantLevel; bool applyUrbanLevel, applyFarmLevel, applyPlantLevel; … public void SetApplyFarmLevel (bool toggle) { applyFarmLevel = toggle; } public void SetFarmLevel (float level) { activeFarmLevel = (int)level; } public void SetApplyPlantLevel (bool toggle) { applyPlantLevel = toggle; } public void SetPlantLevel (float level) { activePlantLevel = (int)level; } … void EditCell (HexCell cell) { if (cell) { … if (applyUrbanLevel) { cell.UrbanLevel = activeUrbanLevel; } if (applyFarmLevel) { cell.FarmLevel = activeFarmLevel; } if (applyPlantLevel) { cell.PlantLevel = activePlantLevel; } … } }

Добавим их в UI.

Три ползунка.

Также дополнительные коллекции понадобятся HexFeatureManager.

public HexFeatureCollection[] urbanCollections, farmCollections, plantCollections;

Три коллекции объектов рельефа.

Для всех них я использовал кубы. Я создал и для ферм, и для растений по два префаба на уровень плотности, как и для коллекций зданий. Фермы имеют светло-зелёный материал, растения — тёмно-зелёный.

1 единицы, чтобы обозначит квадратные наделы сельскохозяйственных земель. Я сделал кубы ферм высотой в 0. 5, 0. В качестве масштабов высокой плотности я выбрал (2. 5) и (3. 1, 2. 1, 2). 5, 0. 75 и размер 2. В среднем площадки имеют площадь 1. 25. 5 на 1. 5 на 0. Низкий уровень плотности получил площадь 1 и размер 1. 75.

Префабы высокой плотности самые большие, (1. Префабы растений обозначают высокие деревья и большие кустарники. 5, 1. 25, 4. 5, 3, 1. 25) и (1. Средние масштабы — (0. 5). 75) и (1, 1. 75, 3, 0. Самые маленькие растения имеют размеры (0. 5, 1). 5, 0. 5, 1. 75, 1, 0. 5) и (0. 75).

Выбор объектов рельефа

Каждый тип объекта должен получать собственное значение хеша, чтобы они имели разные паттерны создания и можно было их смешивать. Добавим в HexHash два дополнительных значения.

public float a, b, c, d, e; public static HexHash Create () { HexHash hash; hash.a = Random.value * 0.999f; hash.b = Random.value * 0.999f; hash.c = Random.value * 0.999f; hash.d = Random.value * 0.999f; hash.e = Random.value * 0.999f; return hash; }

Теперь HexFeatureManager.PickPrefab придётся работать с разными коллекциями. Добавим параметр, чтобы упростить процесс. Также изменим хеш, используемый вариантом выбранного префаба на D, а хеш для поворота — на E.

Transform PickPrefab ( HexFeatureCollection[] collection, int level, float hash, float choice ) { if (level > 0) { float[] thresholds = HexMetrics.GetFeatureThresholds(level - 1); for (int i = 0; i < thresholds.Length; i++) { if (hash < thresholds[i]) { return collection[i].Pick(choice); } } } return null; } public void AddFeature (HexCell cell, Vector3 position) { HexHash hash = HexMetrics.SampleHashGrid(position); Transform prefab = PickPrefab( urbanCollections, cell.UrbanLevel, hash.a, hash.d ); … instance.localRotation = Quaternion.Euler(0f, 360f * hash.e, 0f); instance.SetParent(container, false); }

На текущий момент AddFeature выбирает префаб урбанизации. Это нормально, нам нужно больше вариантов. Поэтому добавим ещё один префаб из ферм. В качестве значения хеша используем B. Выбор варианта снова будет D.

Transform prefab = PickPrefab( urbanCollections, cell.UrbanLevel, hash.a, hash.d ); Transform otherPrefab = PickPrefab( farmCollections, cell.FarmLevel, hash.b, hash.d ); if (!prefab) { return; }

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

Transform otherPrefab = PickPrefab( farmCollections, cell.FarmLevel, hash.b, hash.d ); if (prefab) { if (otherPrefab && hash.b < hash.a) { prefab = otherPrefab; } } else if (otherPrefab) { prefab = otherPrefab; } else { return; }

Смешение городских и сельских объектов.
Далее сделаем то же самое с растениями, воспользовавшись значением хеша C.

if (prefab) { if (otherPrefab && hash.b < hash.a) { prefab = otherPrefab; } } else if (otherPrefab) { prefab = otherPrefab; } otherPrefab = PickPrefab( plantCollections, cell.PlantLevel, hash.c, hash.d ); if (prefab) { if (otherPrefab && hash.c < hash.a) { prefab = otherPrefab; } } else if (otherPrefab) { prefab = otherPrefab; } else { return; }

Однако мы не можем просто скопировать код. Когда мы выберем вместо городского объекта сельский, то нужно сравнить хеш растений с хешем ферм, а не с городским. Поэтому нам нужно отслеживать хеш, который мы решили выбрать, и сравнивать с ним.

float usedHash = hash.a; if (prefab) { if (otherPrefab && hash.b < hash.a) { prefab = otherPrefab; usedHash = hash.b; } } else if (otherPrefab) { prefab = otherPrefab; usedHash = hash.b; } otherPrefab = PickPrefab( plantCollections, cell.PlantLevel, hash.c, hash.d ); if (prefab) { if (otherPrefab && hash.c < usedHash) { prefab = otherPrefab; } } else if (otherPrefab) { prefab = otherPrefab; } else { return; }

Смешение городских, сельских и растительных объектов.

unitypackage

  • Огораживаем ячейки.
  • Строим стены вдоль рёбер ячеек.
  • Позволяем проходить насквозь рекам и дорогам.
  • Избегаем воду и соединяем с обрывами.

В этой части мы добавим между ячейками стены.

Нет ничего более гостеприимного, чем высокая стена.

Редактирование стен

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

Стены, расположенные вдоль рёбер.

Как и другие объекты, мы не будем редактировать их непосредственно. Стены — это объекты рельефа, хотя и большие. Мы не будем располагать отдельные сегменты стен, а займёмся огораживанием ячеек целиком. Вместо этого мы будем изменять ячейки.

Свойство Walled

Для поддержки огороженных ячеек добавим в HexCell свойство Walled. Это простой переключатель. Так как стены располагаются между ячейками, нам нужно обновлять и отредактированные ячейки, и их соседей.

public bool Walled { get { return walled; } set { if (walled != value) { walled = value; Refresh(); } } } bool walled;

Переключатель редактора

Для переключения состояния «огороженности» ячеек нам нужно добавить в HexMapEditor поддержку переключателя. Поэтому добавим ещё одно поле OptionalToggle и метод для его задания.

OptionalToggle riverMode, roadMode, walledMode; … public void SetWalledMode (int mode) { walledMode = (OptionalToggle)mode; }

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

void EditCell (HexCell cell) { if (cell) { … if (roadMode == OptionalToggle.No) { cell.RemoveRoads(); } if (walledMode != OptionalToggle.Ignore) { cell.Walled = walledMode == OptionalToggle.Yes; } if (isDrag) { … } } }

Продублируем один из предыдущих элементов UI переключателей и изменим их так, чтобы они управляли состоянием «огороженности». Я помещу их в панель UI вместе с другими объектами.

Переключатель «огороженности».

unitypackage

Создание стен

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

Дочерний префаб Walls.

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

Управление стенами

Так как стены являются объектами рельефа, ими должен заниматься HexFeatureManager. Поэтому дадим менеджеру объектов рельефа ссылку на объект Walls, и сделаем так, чтобы он вызывал методы Clear и Apply.

public HexMesh walls; … public void Clear () { … walls.Clear(); } public void Apply () { walls.Apply(); }

Стены, соединённые с менеджером объектов рельефа.

Разве Walls не должен быть дочерним элементом Features?

Так как в окне иерархии отображаются только непосредственные дочерние элементы корневых объектов префабов, я предпочитаю оставить Walls непосредственным дочерним элементом Hex Grid Chunk. Мы можем упорядочить объекты и таким образом, но это необязательно.

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

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell ) { }

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

void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { … } else { … } features.AddWall(e1, cell, e2, neighbor); HexCell nextNeighbor = cell.GetNeighbor(direction.Next()); if (direction <= HexDirection.E && nextNeighbor != null) { … } }

Построение сегмента стены

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

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight ) { }

Ближняя и дальняя стороны.

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

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell ) { if (nearCell.Walled != farCell.Walled) { AddWallSegment(near.v1, far.v1, near.v5, far.v5); } }

Простейший сегмент стены — это один quad, стоящий посередине ребра. Мы найдём его нижние вершины, проинтерполировав до середины от ближних к дальним вершинам.

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight ) { Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f); Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f); }

Какой высоты должна быть стена? Давайте зададим её высоту в HexMetrics. Я сделал их размером с один уровень высоты ячеек.

public const float wallHeight = 3f;

HexFeatureManager.AddWallSegment может использовать эту высоту для позиционирования третьей и четвёртой вершины quad-а, а также добавить её в меш walls.

Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f); Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f); Vector3 v1, v2, v3, v4; v1 = v3 = left; v2 = v4 = right; v3.y = v4.y = left.y + HexMetrics.wallHeight; walls.AddQuad(v1, v2, v3, v4);

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

Односторонние quad-ы стен.

Мы можем быстро решить эту проблему, добавив второй quad, направленный в другую сторону.

walls.AddQuad(v1, v2, v3, v4); walls.AddQuad(v2, v1, v4, v3);

Двусторонние стены.

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

Толстые стены

Хотя стены уже видимы с обеих сторон, у них нет толщины. По сути, стены тонкие, как бумага, и почти невидимы под определённым углом. Поэтому давайте сделаем их цельными, добавив толщину. Зададим их толщину в HexMetrics. Я выбрал значение 0.75 единицы, мне оно показалось подходящим.

public const float wallThickness = 0.75f;

Чтобы сделать две стены толстыми, нужно развести два quad-а в стороны. Они должны двигаться в противоположных направлениях. Одна сторона должна сдвинуться к ближнему ребру, другая — к дальнему. Вектор смещения для этого равен far - near, но чтобы оставить верхнюю часть стены плоской, нам нужно присвоить его компоненту Y значение 0.

Так как это нужно делать и для левой, и для правой части сегмента стены, давайте добавим в HexMetrics метод для вычисления этого вектора смещения.

public static Vector3 WallThicknessOffset (Vector3 near, Vector3 far) { Vector3 offset; offset.x = far.x - near.x; offset.y = 0f; offset.z = far.z - near.z; return offset; }

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

return offset.normalized * (wallThickness * 0.5f);

Используем этот метод в HexFeatureManager.AddWallSegment для изменения позиции quad-ов. Так как вектор смещения идёт от ближней к дальней ячейке, вычтем его из ближнего quad и прибавим к дальнему.

Vector3 left = Vector3.Lerp(nearLeft, farLeft, 0.5f); Vector3 right = Vector3.Lerp(nearRight, farRight, 0.5f); Vector3 leftThicknessOffset = HexMetrics.WallThicknessOffset(nearLeft, farLeft); Vector3 rightThicknessOffset = HexMetrics.WallThicknessOffset(nearRight, farRight); Vector3 v1, v2, v3, v4; v1 = v3 = left - leftThicknessOffset; v2 = v4 = right - rightThicknessOffset; v3.y = v4.y = left.y + HexMetrics.wallHeight; walls.AddQuad(v1, v2, v3, v4); v1 = v3 = left + leftThicknessOffset; v2 = v4 = right + rightThicknessOffset; v3.y = v4.y = left.y + HexMetrics.wallHeight; walls.AddQuad(v2, v1, v4, v3);

Стены со смещениями.

Quad-ы теперь смещены, хотя это и не совсем заметно.

Действительно ли толщина стен одинакова?

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

Верхушки стен

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

Vector3 v1, v2, v3, v4; v1 = v3 = left - leftThicknessOffset; v2 = v4 = right - rightThicknessOffset; v3.y = v4.y = left.y + HexMetrics.wallHeight; walls.AddQuad(v1, v2, v3, v4); Vector3 t1 = v3, t2 = v4; v1 = v3 = left + leftThicknessOffset; v2 = v4 = right + rightThicknessOffset; v3.y = v4.y = left.y + HexMetrics.wallHeight; walls.AddQuad(v2, v1, v4, v3); walls.AddQuad(t1, t2, v3, v4);

Стены с верхушками.

Повороты на углах

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

Конфигурации углов.

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

Роли ячеек.

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

void AddWallSegment ( Vector3 pivot, HexCell pivotCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { AddWallSegment(pivot, left, pivot, right); }

Далее создадим вариант метода AddWall для трёх вершин угла и их ячеек. Задача этого метода заключается в определении угла, являющегося опорной точкой, если она существует. Поэтому он должен рассматривать все восемь возможных конфигураций и вызывать AddWallSegment для шести из них.

public void AddWall ( Vector3 c1, HexCell cell1, Vector3 c2, HexCell cell2, Vector3 c3, HexCell cell3 ) { if (cell1.Walled) { if (cell2.Walled) { if (!cell3.Walled) { AddWallSegment(c3, cell3, c1, cell1, c2, cell2); } } else if (cell3.Walled) { AddWallSegment(c2, cell2, c3, cell3, c1, cell1); } else { AddWallSegment(c1, cell1, c2, cell2, c3, cell3); } } else if (cell2.Walled) { if (cell3.Walled) { AddWallSegment(c1, cell1, c2, cell2, c3, cell3); } else { AddWallSegment(c2, cell2, c3, cell3, c1, cell1); } } else if (cell3.Walled) { AddWallSegment(c3, cell3, c1, cell1, c2, cell2); } }

Для добавления угловых сегментов вызовем этот метод в конце HexGridChunk.TriangulateCorner.

void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); }

Стены с углами, но дыры всё ещё есть.

Закрываем дыры

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

Чтобы исправить это, изменим AddWallSegment, чтобы он хранил по отдельности координаты Y левой и правой верхних вершин.

float leftTop = left.y + HexMetrics.wallHeight; float rightTop = right.y + HexMetrics.wallHeight; Vector3 v1, v2, v3, v4; v1 = v3 = left - leftThicknessOffset; v2 = v4 = right - rightThicknessOffset; v3.y = leftTop; v4.y = rightTop; walls.AddQuad(v1, v2, v3, v4); Vector3 t1 = v3, t2 = v4; v1 = v3 = left + leftThicknessOffset; v2 = v4 = right + rightThicknessOffset; v3.y = leftTop; v4.y = rightTop; walls.AddQuad(v2, v1, v4, v3);

Замкнутые стены.

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

Или же изменить режим Cast Shadows mesh renderer стены на Two Sided. Можно избавиться от этих артефактов теней, снизив normal bias до нуля. Это заставит отбрасывающий тень объект передавать на рендеринг обе стороны каждого треугольника стены, что закроет все дыры.

Больше дыр в тенях нет.

unitypackage

Стены на уступах

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

Прямые стены на уступах.

Следуем за ребром

Вместо создания одного сегмента для всего ребра, мы будем создавать по одному для каждой части полосы ребра. Мы можем сделать это, вызывая четыре раза AddWallSegment в версии AddWall для рёбер.

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell ) { if (nearCell.Walled != farCell.Walled) { AddWallSegment(near.v1, far.v1, near.v2, far.v2); AddWallSegment(near.v2, far.v2, near.v3, far.v3); AddWallSegment(near.v3, far.v3, near.v4, far.v4); AddWallSegment(near.v4, far.v4, near.v5, far.v5); } }

Изгибающиеся стены.

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

Размещение стен на земле

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

Висящие в воздухе стены.

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

Мы можем просто использовать высоту самой низкой стороны, но так низко нам опускаться не нужно. Чтобы опустить стену, нам нужно определить, какая из сторон нижняя — ближняя или дальняя. 5. Можно интерполировать координату Y от низкой к высокой со смещением чуть меньше 0. Другая толщина стен конфигурации уступов может потребовать другого смещения. Так как стены только изредка становятся выше нижней ступеньки уступа, мы можем использовать в качестве смещения вертикальный шаг уступа.

Опущенная стена.

Он основан на методе TerraceLerp. Давайте добавим в HexMetrics метод WallLerp, который займётся этой интерполяцией, в дополнение к усреднению координат X и Z ближних и дальних вершин.

public const float wallElevationOffset = verticalTerraceStepSize; … public static Vector3 WallLerp (Vector3 near, Vector3 far) { near.x += (far.x - near.x) * 0.5f; near.z += (far.z - near.z) * 0.5f; float v = near.y < far.y ? wallElevationOffset : (1f - wallElevationOffset); near.y += (far.y - near.y) * v; return near; }

Заставим HexFeatureManager использовать этот метод для определения левых и правых вершин.

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight ) { Vector3 left = HexMetrics.WallLerp(nearLeft, farLeft); Vector3 right = HexMetrics.WallLerp(nearRight, farRight); … }

Стоящие на земле стены.

Изменение искажения стен

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

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

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight ) { nearLeft = HexMetrics.Perturb(nearLeft); farLeft = HexMetrics.Perturb(farLeft); nearRight = HexMetrics.Perturb(nearRight); farRight = HexMetrics.Perturb(farRight); … walls.AddQuadUnperturbed(v1, v2, v3, v4); … walls.AddQuadUnperturbed(v2, v1, v4, v3); walls.AddQuadUnperturbed(t1, t2, v3, v4); }

Неискажённые вершины стен.

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

Более постоянная толщина стен.

unitypackage

Отверстия в стенах

Пока мы игнорировали возможность того, что стену могут пересечь река или дорога. Когда такое происходит, мы должны делать в стене дыру, через которую может пройти река или дорога.

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

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell, bool hasRiver, bool hasRoad ) { if (nearCell.Walled != farCell.Walled) { AddWallSegment(near.v1, far.v1, near.v2, far.v2); if (hasRiver || hasRoad) { // Leave a gap. } else { AddWallSegment(near.v2, far.v2, near.v3, far.v3); AddWallSegment(near.v3, far.v3, near.v4, far.v4); } AddWallSegment(near.v4, far.v4, near.v5, far.v5); } }

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

void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … bool hasRiver = cell.HasRiverThroughEdge(direction); bool hasRoad = cell.HasRoadThroughEdge(direction); if (hasRiver) { … } if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { TriangulateEdgeStrip(e1, cell.Color, e2, neighbor.Color, hasRoad); } features.AddWall(e1, cell, e2, neighbor, hasRiver, hasRoad); … }

Отверстия в стенах для прохода рек и дорог.

Накрываем стены

Эти новые отверстия создают места завершения стен. Нам нужно закрыть эти конечные точки quad-ами, чтобы нельзя было смотреть сквозь бока стен. Создадим для этой цели в HexFeatureManager метод AddWallCap. Он работает как AddWallSegment, но ему требуется только одна пара ближних-далёких вершин. Заставим его добавлять quad, идущий от ближней до дальней стороны стены.

void AddWallCap (Vector3 near, Vector3 far) { near = HexMetrics.Perturb(near); far = HexMetrics.Perturb(far); Vector3 center = HexMetrics.WallLerp(near, far); Vector3 thickness = HexMetrics.WallThicknessOffset(near, far); Vector3 v1, v2, v3, v4; v1 = v3 = center - thickness; v2 = v4 = center + thickness; v3.y = v4.y = center.y + HexMetrics.wallHeight; walls.AddQuadUnperturbed(v1, v2, v3, v4); }

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

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell, bool hasRiver, bool hasRoad ) { if (nearCell.Walled != farCell.Walled) { AddWallSegment(near.v1, far.v1, near.v2, far.v2); if (hasRiver || hasRoad) { AddWallCap(near.v2, far.v2); AddWallCap(far.v4, near.v4); } … } }

Закрытые отверстия в стенах.

А как насчёт дыр по краям карты?

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

unitypackage

Избегаем обрывов и воды

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

Стены на обрывах и в воде.

Стена не может находиться под водой, и общее с ней ребро не может быть обрывом. Мы можем удалить стены с этих ненужных рёбер дополнительными проверками в AddWall.

public void AddWall ( EdgeVertices near, HexCell nearCell, EdgeVertices far, HexCell farCell, bool hasRiver, bool hasRoad ) { if ( nearCell.Walled != farCell.Walled && !nearCell.IsUnderwater && !farCell.IsUnderwater && nearCell.GetEdgeType(farCell) != HexEdgeType.Cliff ) { … } }

Удалили мешающие стены вдоль рёбер, но углы остались на месте.

Удаление углов стен

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

void AddWallSegment ( Vector3 pivot, HexCell pivotCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { if (pivotCell.IsUnderwater) { return; } AddWallSegment(pivot, left, pivot, right); }

Подводных опорных ячеек больше нет.

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

Поместим результаты в булевы переменные, чтобы с ними легче было работать. По отдельности определим, есть ли левая или правая стена.

if (pivotCell.IsUnderwater) { return; } bool hasLeftWall = !leftCell.IsUnderwater && pivotCell.GetEdgeType(leftCell) != HexEdgeType.Cliff; bool hasRighWall = !rightCell.IsUnderwater && pivotCell.GetEdgeType(rightCell) != HexEdgeType.Cliff; if (hasLeftWall && hasRighWall) { AddWallSegment(pivot, left, pivot, right); }

Удалили все мешающие углы.

Закрываем углы

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

if (hasLeftWall) { if (hasRighWall) { AddWallSegment(pivot, left, pivot, right); } else { AddWallCap(pivot, left); } } else if (hasRighWall) { AddWallCap(right, pivot); }

Закрываем стены.

Соединение стен с обрывами

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

Дыры между стенами и гранями обрывов.

Мы можем сделать это, добавив ещё один сегмент стены между текущим концом стены и угловой вершиной обрыва. Было бы гораздо лучше, если бы стена продолжалась до самой грани обрыва. Таким образом, нам достаточно создать клин: два идущих в точку quad-а и треугольник поверх них. Так как большая часть этого сегмента окажется скрытой внутри обрыва, мы можем обойтись снижением до нуля толщины стены внутри обрыва. Это можно сделать, скопировав AddWallCap и добавив точку клина. Создадим для этой цели метод AddWallWedge.

void AddWallWedge (Vector3 near, Vector3 far, Vector3 point) { near = HexMetrics.Perturb(near); far = HexMetrics.Perturb(far); point = HexMetrics.Perturb(point); Vector3 center = HexMetrics.WallLerp(near, far); Vector3 thickness = HexMetrics.WallThicknessOffset(near, far); Vector3 v1, v2, v3, v4; Vector3 pointTop = point; point.y = center.y; v1 = v3 = center - thickness; v2 = v4 = center + thickness; v3.y = v4.y = pointTop.y = center.y + HexMetrics.wallHeight; // walls.AddQuadUnperturbed(v1, v2, v3, v4); walls.AddQuadUnperturbed(v1, point, v3, pointTop); walls.AddQuadUnperturbed(point, v2, pointTop, v4); walls.AddTriangleUnperturbed(pointTop, v3, v4); }

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

if (hasLeftWall) { if (hasRighWall) { AddWallSegment(pivot, left, pivot, right); } else if (leftCell.Elevation < rightCell.Elevation) { AddWallWedge(pivot, left, right); } else { AddWallCap(pivot, left); } } else if (hasRighWall) { if (rightCell.Elevation < leftCell.Elevation) { AddWallWedge(right, pivot, left); } else { AddWallCap(right, pivot); } }

Клиновидные стены, соединяющиеся с обрывами.

unitypackage

  • Добавляем к стенам башни.
  • Соединяем дороги через реки мостами.
  • Добавляем поддержку крупных особых объектов.

Заполненный объектами ландшафт.

Башни на стенах

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

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

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

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

Префаб башни.

Добавим ссылку на этот префаб в HexFeatureManager и подключим его.

public Transform wallTower;

Ссылка на префаб башни.

Строим башни

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

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight ) { … Transform towerInstance = Instantiate(wallTower); towerInstance.transform.localPosition = (left + right) * 0.5f; towerInstance.SetParent(container, false); }

По одной башне на каждый сегмент стены.

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

Код Unity займётся изменением поворота объекта так, чтобы его локальное направление right соответствовало переданному вектору. Вместо того, чтобы вычислять поворот самостоятельно, мы просто присвоим свойству Transform.right вектор.

Transform towerInstance = Instantiate(wallTower); towerInstance.transform.localPosition = (left + right) * 0.5f; Vector3 rightDirection = right - left; rightDirection.y = 0f; towerInstance.transform.right = rightDirection; towerInstance.SetParent(container, false);

Башни выровнены со стеной.

Как работает присвоение Transform.right?

В нём используется метод Quaternion.FromToRotation для вычисления поворота. Вот код свойства.

public Vector3 right { get { return rotation * Vector3.right; } set { rotation = Quaternion.FromToRotation(Vector3.right, value); }
}

Уменьшаем количество башен

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

void AddWallSegment ( Vector3 nearLeft, Vector3 farLeft, Vector3 nearRight, Vector3 farRight, bool addTower = false ) { … if (addTower) { Transform towerInstance = Instantiate(wallTower); towerInstance.transform.localPosition = (left + right) * 0.5f; Vector3 rightDirection = right - left; rightDirection.y = 0f; towerInstance.transform.right = rightDirection; towerInstance.SetParent(container, false); } }

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

void AddWallSegment ( Vector3 pivot, HexCell pivotCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … AddWallSegment(pivot, left, pivot, right, true); … }

Башни находятся только в углах.

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

HexHash hash = HexMetrics.SampleHashGrid( (pivot + left + right) * (1f / 3f) ); bool hasTower = hash.e < HexMetrics.wallTowerThreshold; AddWallSegment(pivot, left, pivot, right, hasTower);

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

public const float wallTowerThreshold = 0.5f;

Случайные башни.

Убираем башни со склонов

Сейчас мы размещаем башни вне зависимости от формы рельефа. Однако на склонах башни выглядят нелогично. Здесь стены идут под углом и могут прорезать верхушку башни.

Башни на склонах.

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

bool hasTower = false; if (leftCell.Elevation == rightCell.Elevation) { HexHash hash = HexMetrics.SampleHashGrid( (pivot + left + right) * (1f / 3f) ); hasTower = hash.e < HexMetrics.wallTowerThreshold; } AddWallSegment(pivot, left, pivot, right, hasTower);

Больше на стенах склонов башен нет.

Ставим стены и башни на землю

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

Башни в воздухе.

На самом деле, башни на склонах тоже могут висеть в воздухе, но это не так заметно, как для башен.

Стены в воздухе.

Для этого добавим смещение по Y для стен в HexMetrics. Это можно исправить, растянув основание стен и башен до земли. Увеличим высоту башен на ту же величину. На одну единицу вниз будет достаточно.

public const float wallHeight = 4f; public const float wallYOffset = -1f;

Изменим HexMetrics.WallLerp так, чтобы при определении координаты Y он учитывал новое смещение.

public static Vector3 WallLerp (Vector3 near, Vector3 far) { near.x += (far.x - near.x) * 0.5f; near.z += (far.z - near.z) * 0.5f; float v = near.y < far.y ? wallElevationOffset : (1f - wallElevationOffset); near.y += (far.y - near.y) * v + wallYOffset; return near; }

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

Стены и башни на земле.

unitypackage

Мосты

На данном этапе у нас есть реки и дороги, но дороги никак не могут пересекать реки. Настало подходящее время для добавления мостов.

Ширина рек варьируется, но между центрами дорог с обеих сторон есть примерно семь единиц расстояния. Начнём с простого отмасштабированного куба, который будет играть роль префаба моста. Добавим префабу красный городской материал и избавимся от его коллайдера. Поэтому зададим ему приблизительный масштаб (3, 1, 7). Благодаря этому сама геометрия моста будет не важна. Как и в случае с башнями, поместим куб внутрь корневого объекта с одинаковым масштабом.

Добавим ссылку на префаб моста в HexFeatureManager и назначим ей префаб.

public Transform wallTower, bridge;

Назначенный префаб моста.

Размещение мостов

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

public void AddBridge (Vector3 roadCenter1, Vector3 roadCenter2) { Transform instance = Instantiate(bridge); instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f; instance.SetParent(container, false); }

Мы будем передавать неискажённые центры дорог, поэтому нам придётся исказить их перед размещением моста.

roadCenter1 = HexMetrics.Perturb(roadCenter1); roadCenter2 = HexMetrics.Perturb(roadCenter2); Transform instance = Instantiate(bridge);

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

Transform instance = Instantiate(bridge); instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f; instance.forward = roadCenter2 - roadCenter1; instance.SetParent(container, false);

Прокладываем мосты через прямые реки

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

Внутри HexGridChunk. Для начала давайте разберёмся с прямыми реками. Поэтому здесь мы и будем добавлять мосты. TriangulateRoadAdjacentToRiver первый оператор else if занимается размещением дорог по соседству с такими реками.

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

void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) { … roadCenter += corner * 0.5f; features.AddBridge(roadCenter, center - corner * 0.5f); center += corner * 0.25f; } … }

Мосты через прямые реки.

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

roadCenter += corner * 0.5f; if (cell.IncomingRiver == direction.Next()) { features.AddBridge(roadCenter, center - corner * 0.5f); } center += corner * 0.25f;

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

if (cell.IncomingRiver == direction.Next() && ( cell.HasRoadThroughEdge(direction.Next2()) || cell.HasRoadThroughEdge(direction.Opposite()) )) { features.AddBridge(roadCenter, center - corner * 0.5f); }

Мосты между дорогами по обеим сторонам.

Мосты над искривлёнными реками

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

void TriangulateRoadAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … else { HexDirection middle; if (previousHasRiver) { middle = direction.Next(); } else if (nextHasRiver) { middle = direction.Previous(); } else { middle = direction; } if ( !cell.HasRoadThroughEdge(middle) && !cell.HasRoadThroughEdge(middle.Previous()) && !cell.HasRoadThroughEdge(middle.Next()) ) { return; } Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle); roadCenter += offset * 0.25f; } … }

Масштаб смещения на внешней стороне кривой равен 0.25, а внутри HexMetrics.innerToOuter * 0.7f. Используем его для размещения моста.

Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle); roadCenter += offset * 0.25f; features.AddBridge( roadCenter, center - offset * (HexMetrics.innerToOuter * 0.7f) );

Мосты над искривлёнными реками.

Мы можем это сделать, добавляя мосты только из среднего направления. Здесь нам снова нужно избежать дубликатов мостов.

Vector3 offset = HexMetrics.GetSolidEdgeMiddle(middle); roadCenter += offset * 0.25f; if (direction == middle) { features.AddBridge( roadCenter, center - offset * (HexMetrics.innerToOuter * 0.7f) ); }

И опять нужно убедиться, что дорога есть и на противоположной стороне.

if ( direction == middle && cell.HasRoadThroughEdge(direction.Opposite()) ) { features.AddBridge( roadCenter, center - offset * (HexMetrics.innerToOuter * 0.7f) ); }

Мосты между дорогами по обеим сторонам.

Масштабирование мостов

Так как мы искажаем рельеф, расстояние между центрами дорог и противоположными сторонами реки варьируются. Иногда мосты слишком короткие, иногда слишком длинные.

Варьирующиеся расстояния, но постоянная длина мостов.

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

Будем хранить эту длину в HexMetrics. Для выполнения правильного масштабирования нам нужно знать исходную длину префаба моста.

public const float bridgeDesignLength = 7f;

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

public void AddBridge (Vector3 roadCenter1, Vector3 roadCenter2) { roadCenter1 = HexMetrics.Perturb(roadCenter1); roadCenter2 = HexMetrics.Perturb(roadCenter2); Transform instance = Instantiate(bridge); instance.localPosition = (roadCenter1 + roadCenter2) * 0.5f; instance.forward = roadCenter2 - roadCenter1; float length = Vector3.Distance(roadCenter1, roadCenter2); instance.localScale = new Vector3( 1f, 1f, length * (1f / HexMetrics.bridgeDesignLength) ); instance.SetParent(container, false); }

Изменяющаяся длина мостов.

Конструкция моста

Вместо простого куба мы можем использовать более интересную модель моста. Например, можно создать грубый арочный мост из трёх отмасштабированных и повёрнутых кубов. Разумеется, можно создать гораздо более сложные 3D-модели, в том числе и с частями дороги. Но учтите, что весь объект будет немного сжат и растянут.

Арочные мосты разной длины.

unitypackage

Особые объекты

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

Такие объекты настолько велики, что занимают всю ячейку. Давайте добавим к рельефу особый тип объектов. Например, простой замок можно создать из одного центрального куба плюс четырёх угловых башен. Каждый из этих объектов уникален и ему нужен собственный префаб. Масштаб (6, 4, 6) для центрального куба создаст достаточно большой замок, который тем не менее поместится даже в сильно деформированную ячейку.

Префаб замка.

Для нижнего куба подойдёт масштаб (8, 2. Ещё одним специальным объектом может быть зиккурат, например, построенный из трёх поставленных друг на друга кубов. 5, 8).

Префаб зиккурата.

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

Префаб мегафлоры.

Добавим в HexFeatureManager массив, чтобы отслеживать эти префабы.

public Transform[] special;

Сначала добавим в массив замок, затем зиккурат, а потом мегафлору.

Настройка особых объектов.

Делаем ячейки особыми

Теперь для HexCell требуется индекс особых объектов, определяющий тип особого объекта, если он там находится.

int specialIndex;

Как и другим объектам рельефа, дадим ему свойство получать и задавать это значение.

public int SpecialIndex { get { return specialIndex; } set { if (specialIndex != value) { specialIndex = value; RefreshSelfOnly(); } } }

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

public bool IsSpecial { get { return specialIndex > 0; } }

Для редактирования ячеек добавим поддержку индекса особых объектов в HexMapEditor. Он работает аналогично уровням городских, сельских и растительных объектов.

int activeUrbanLevel, activeFarmLevel, activePlantLevel, activeSpecialIndex; … bool applyUrbanLevel, applyFarmLevel, applyPlantLevel, applySpecialIndex; … public void SetApplySpecialIndex (bool toggle) { applySpecialIndex = toggle; } public void SetSpecialIndex (float index) { activeSpecialIndex = (int)index; } … void EditCell (HexCell cell) { if (cell) { if (applyColor) { cell.Color = activeColor; } if (applyElevation) { cell.Elevation = activeElevation; } if (applyWaterLevel) { cell.WaterLevel = activeWaterLevel; } if (applySpecialIndex) { cell.SpecialIndex = activeSpecialIndex; } if (applyUrbanLevel) { cell.UrbanLevel = activeUrbanLevel; } … } }

Добавим в UI ползунок для управления особым объектом. Так как у нас три объекта, используем в ползунке интервал 0–3. Ноль будет означать отсутствие объекта, один — замок, два — зиккурат, три — мегафлору.

Ползунок особых объектов.

Добавление особых объектов

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

public void AddSpecialFeature (HexCell cell, Vector3 position) { Transform instance = Instantiate(special[cell.SpecialIndex - 1]); instance.localPosition = HexMetrics.Perturb(position); instance.SetParent(container, false); }

Придадим объекту произвольный поворот с помощью таблицы хешей.

public void AddSpecialFeature (HexCell cell, Vector3 position) { Transform instance = Instantiate(special[cell.SpecialIndex - 1]); instance.localPosition = HexMetrics.Perturb(position); HexHash hash = HexMetrics.SampleHashGrid(position); instance.localRotation = Quaternion.Euler(0f, 360f * hash.e, 0f); instance.SetParent(container, false); }

При триангуляции ячейки в HexGridChunk.Triangulate проверим, содержит ли ячейка особый объект. Если да, то вызываем наш новый метод, точно так же, как AddFeature.

void Triangulate (HexCell cell) { for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { Triangulate(d, cell); } if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) { features.AddFeature(cell, cell.Position); } if (cell.IsSpecial) { features.AddSpecialFeature(cell, cell.Position); } }

Особые объекты. Они намного больше обычных.

Избегаем рек

Так как особые объекты находятся в центрах ячеек, они не сочетаются с реками, потому что будут висеть над ними.

Объекты на реках.

SpecialIndex. Чтобы особые объекты не создавались поверх рек, изменим свойство HexCell. Будем менять индекс только тогда, когда в ячейке нет рек.

public int SpecialIndex { … set { if (specialIndex != value && !HasRiver) { specialIndex = value; RefreshSelfOnly(); } } }

Кроме того, при добавлении реки нам нужно будет избавляться от всех особых объектов. Река должна смывать их. Это можно сделать, присваивая в методе HexCell.SetOutgoingRiver индексам особых объектов значение 0.

public void SetOutgoingRiver (HexDirection direction) { … hasOutgoingRiver = true; outgoingRiver = direction; specialIndex = 0; neighbor.RemoveIncomingRiver(); neighbor.hasIncomingRiver = true; neighbor.incomingRiver = direction.Opposite(); neighbor.specialIndex = 0; SetRoad((int)direction, false); }

Избегаем дорог

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

Объекты на дорогах.

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

public int SpecialIndex { … set { if (specialIndex != value && !HasRiver) { specialIndex = value; RemoveRoads(); RefreshSelfOnly(); } } }

Что, если мы удалим особый объект?

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

Кроме того, это значит, что при добавлении дорог нам придётся выполнять дополнительные проверки. Мы будем добавлять дороги только когда ни одна из ячейка не является ячейкой с особым объектом.

public void AddRoad (HexDirection direction) { if ( !roads[(int)direction] && !HasRiverThroughEdge(direction) && !IsSpecial && !GetNeighbor(direction).IsSpecial && GetElevationDifference(direction) <= 1 ) { SetRoad((int)direction, true); } }

Избегаем других объектов

Особые объекты нельзя смешивать и с другими типами объектов. Если они наложатся друг на друга, то выглядеть это будет неопрятно. Это тоже может зависеть от особого объекта, но мы воспользуемся одинаковым подходом.

Объект, пересекающийся с другими объектами.

На этот раз проверку будем выполнять в HexFeatureManager. В этом случае мы будем подавлять меньшие объекты, как будто они оказались под водой. AddFeature.

public void AddFeature (HexCell cell, Vector3 position) { if (cell.IsSpecial) { return; } … }

Избегаем воду

Также у нас есть проблема с водой. Будут ли особые объекты сохраняться при затоплении? Так как мы уничтожаем мелкие объекты в затопленных ячейках, давайте сделаем то же самое с особыми объектами.

Объекты в воде.

Triangulate будем выполнять одинаковую проверку затопленности и для особых, и для обычных объектов. В HexGridChunk.

void Triangulate (HexCell cell) { for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { Triangulate(d, cell); } if (!cell.IsUnderwater && !cell.HasRiver && !cell.HasRoads) { features.AddFeature(cell, cell.Position); } if (!cell.IsUnderwater && cell.IsSpecial) { features.AddSpecialFeature(cell, cell.Position); } }

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

void Triangulate (HexCell cell) { for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { Triangulate(d, cell); } if (!cell.IsUnderwater) { if (!cell.HasRiver && !cell.HasRoads) { features.AddFeature(cell, cell.Position); } if (cell.IsSpecial) { features.AddSpecialFeature(cell, cell.Position); } } }

Для экспериментов такого количества объектов нам будет достаточно.

unitypackage


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

5-нм на подходе — когда ждать новый техпроцесс

В начале октября тайваньский производитель чипов TSMC, который работает с такими компаниями, как AMD и Apple, сделал два заявления. Первое — компании удалось улучшить свой 7-нм техпроцесс и изготовить чип по новой технологии. Второе — 5-нанометровый чип выйдет в 2019 ...

Почему он нам не перезвонил-5, или Как я убежал с интервью в фирму, работать в которой — большая честь

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