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

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

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


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


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

2a973cb6eb97eba7d04a325f3ac89a3e.jpg


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

Уровень воды


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

  public int WaterLevel {
                get {
                        return 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 уровень воды, дублируем метку и ползунок высоты, а потом изменим их. Не забудьте прикрепить их события к соответствующим методам.

42ba13de4120b43e460628021c77d99f.png


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

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 и заменив его шейдер. Оставим текстуру шума, потому что позже её используем.

fd9238283d92bacd26a3ff5dce73b17b.png


Материал Water.

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

6ec6e682f16c604d75c0e5b835d5b03e.png


7c53f3eeb282e533f9f0b8a20e813091.png


Дочерний объект 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();
        }


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

b4d19a76a745d550314b277fd2c0e13c.png


Объект 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);
        }


a5172b99ba382c6e79abff653e4b7fb2.png


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

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


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

          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);
                }


0a63af9496da925e966b008f41f808a8.png


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

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

          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())
                                );
                        }
                }


c3237e5b4e492f9a1c334f90aaa88ee4.png


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

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

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


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

fcfa8bb4fb960ddc788d358e60f9023f.png


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

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

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

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

unitypackage

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


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

ca1a2f1f978cd2f2993a96b44440227b.png


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

Давайте сделаем то же, что делали с реками. Сэмплируем шум с позицией мира и прибавим его к однородному цвету. Чтобы анимировать поверхность, прибавим к координате 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);


aa33d9d70a022df57a0348423181b258.png


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

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

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


618c8f697cd2e463d7349030277d1a82.png


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

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

                     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
        ) {
                
        }


f0479944178dcb5646bbf935700a798e.png


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

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

  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);
        }


bbd76128386859bb388e6e2d3f105ff0.png


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

Далее идёт полоса рёбер, как и в обычном рельефе. Однако мы не обязаны ограничиваться только некоторыми направлениями, потому что мы вызываем 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);


7dafbcaa95a4b48509ab68fbf111c703.png


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

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

          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())
                        );
                }


06418f8b10cc0fc0a4e2276b86375385.png


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

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

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-координаты. Также создадим шейдер и материал для прибрежной воды, продублировав уже имеющиеся шейдер и материал воды.

56177ee7331ce092a859a058645f0364.png


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

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

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


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

63045cec0f1f367bfee191f69cfa11ad.png


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

Давайте поместим информацию о побережье в координату V. Со стороны воды присвоим ей значение 0, со стороны суши — значение 1. Так как больше нам передавать ничего не нужно, все координаты 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)
                        );
                }


8f43faf864c259e21c2ce731d08f43f6.png


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

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

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


c2e9e96e63c161d0f71f81e31742af89.png


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

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


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

             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;
                }


ef5502a56ab665732312be25a6961817.png


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

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

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


c2eacf6d83e9f7c1298cdd7a05e551d0.png


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

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

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


762675e987d119034cd5b643c86ca672.png


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

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

                     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;


31a7fd59ad215b200571133543f1406b.png


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

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

                     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

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


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

deaadf7e3290f93b28ed6177c713bbaa.png


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

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

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

  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)
                );
                …
        }


123b85fcffde7ae313f177f76f882ef5.png


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

Расстояние между шестиугольниками воды и в самом деле удвоилось. Теперь 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())
                        );
                        …
                }
        }


99b7561fad8556733ea1f10b2dcdd233.png


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

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


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

Мы не можем использовать простой мост для нахождения противоположного ребра суши, если начнём с углов воды. Вместо этого, мы можем идти в обратном направлении, от центра соседа. Изменим 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())
                        );
                        …
                }


b293f201f065b7921f1098484e3b725d.png


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

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

          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)
                        );
                }


4e1f64db1a34a8e6cff0f402f5fc6bd8.png


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

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

     shore = sqrt(shore) * 0.9;


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

unitypackage

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


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

0511eebc5420c9203475b65ce5c478b3.png


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

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

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


4bc74ed9179598f5b9e82c3ff0cde851.png


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

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


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

  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
                                );
                        }
                }


8990c7cf6af7bf25596b4ffc05204817.png


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

Водопады


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

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

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

  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 будет недостаточно. Э

© Habrahabr.ru