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

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

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

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

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

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


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


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

Теперь серия будет создаваться на Unity 2017.1.0.

716b6b8a9d024dc87b2d645b5d4bdbb6.jpg


Теперь мы видим, что можем и не можем видеть.

Данные ячеек в шейдере


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

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

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

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

А как насчёт использования массивов шейдера?

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


Управление данными ячеек


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

using UnityEngine;

public class HexCellShaderData : MonoBehaviour {
        
        Texture2D cellTexture;
}


При создании или загрузке новой карты нам нужно создавать новую текстуру с правильным размером. Поэтому добавим ему метод инициализации, создающий текстуру. Мы используем текстуру в формате RGBA без mip-текстур и линейное цветовое пространство. Нам не нужно смешивать данные ячеек, поэтому используем точечную фильтрацию (point filtering). Кроме того, данные не должны сворачиваться. Каждый пиксель текстуры будет содержать данные одной ячейки.

  public void Initialize (int x, int z) {
                cellTexture = new Texture2D(
                        x, z, TextureFormat.RGBA32, false, true
                );
                cellTexture.filterMode = FilterMode.Point;
                cellTexture.wrapMode = TextureWrapMode.Clamp;
        }


Должен ли размер текстуры соответствовать размеру карты?

Нет, в ней просто должно быть достаточно пикселей для хранения всех ячеек. При точном соответствии размеру карты скорее всего будет создана текстура с размерами, не являющимися степенями двойки (non-power-of-two, NPOT), а такой формат текстур является не самым эффективным. Хоть мы и можем настроить код на работу с текстурами размером в степень двойки, это незначительная оптимизация, которая усложняет доступ к данным ячеек.


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

  public void Initialize (int x, int z) {
                if (cellTexture) {
                        cellTexture.Resize(x, z);
                }
                else {
                        cellTexture = new Texture2D(
                                cellCountX, cellCountZ, TextureFormat.RGBA32, false, true
                        );
                        cellTexture.filterMode = FilterMode.Point;
                        cellTexture.wrapMode = TextureWrapMode.Clamp;
                }
        }


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

  Texture2D cellTexture;
        Color32[] cellTextureData;
        
        public void Initialize () {
                …
                
                if (cellTextureData == null || cellTextureData.Length != x * z) {
                        cellTextureData = new Color32[x * z];
                }
                else {
                        for (int i = 0; i < cellTextureData.Length; i++) {
                                cellTextureData[i] = new Color32(0, 0, 0, 0);
                        }
                }
        }


Что такое Color32?
Стандартные несжатые RGBA-текстуры содержат пиксели размером четыре байта. Каждый из четырёх цветовых каналов получает по байту, то есть они имеют 256 возможных значений. При использовании структуры Unity Color её компоненты с плавающей запятой в интервале 0–1 преобразуются в байты в интервале 0–255. При сэмплировании GPU выполняет обратное преобразование.

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


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

  HexCellShaderData cellShaderData;

        void Awake () {
                HexMetrics.noiseSource = noiseSource;
                HexMetrics.InitializeHashGrid(seed);
                HexUnit.unitPrefab = unitPrefab;
                cellShaderData = gameObject.AddComponent();
                CreateMap(cellCountX, cellCountZ);
        }


При создании новой карты должен инициироваться и cellShaderData.

  public bool CreateMap (int x, int z) {
                …

                cellCountX = x;
                cellCountZ = z;
                chunkCountX = cellCountX / HexMetrics.chunkSizeX;
                chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
                cellShaderData.Initialize(cellCountX, cellCountZ);
                CreateChunks();
                CreateCells();
                return true;
        }


Изменение данных ячеек


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

  public HexCellShaderData ShaderData { get; set; }


В HexGrid.CreateCell присвоим этому свойству компонент данных шейдера.

  void CreateCell (int x, int z, int i) {
                …

                HexCell cell = cells[i] = Instantiate(cellPrefab);
                cell.transform.localPosition = position;
                cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
                cell.ShaderData = cellShaderData;
                
                …
        }


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

Добавим в HexCellShaderData метод RefreshTerrain, чтобы упростить эту задачу для конкретной ячейки. Давайте пока оставим этот метод пустым.

  public void RefreshTerrain (HexCell cell) {
        }


Изменим HexCell.TerrainTypeIndex так, чтобы он вызывал этот метод, а не приказывал обновлять фрагменты.

  public int TerrainTypeIndex {
                get {
                        return terrainTypeIndex;
                }
                set {
                        if (terrainTypeIndex != value) {
                                terrainTypeIndex = value;
//                              Refresh();
                                ShaderData.RefreshTerrain(this);
                        }
                }
        }


Также вызовем его в HexCell.Load после получения типа рельефа ячейки.

  public void Load (BinaryReader reader) {
                terrainTypeIndex = reader.ReadByte();
                ShaderData.RefreshTerrain(this);
                elevation = reader.ReadByte();
                RefreshPosition();
                …
        }


Индекс ячейки


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

  public int Index { get; set; }


Этот индекс уже есть в HexGrid.CreateCell, поэтому просто присвоим его созданной ячейке.

  void CreateCell (int x, int z, int i) {
                …
                cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
                cell.Index = i;
                cell.ShaderData = cellShaderData;

                …
        }


Теперь HexCellShaderData.RefreshTerrain может использовать этот индекс для задания данных ячейки. Давайте сохранять индекс типа рельефа в альфа-компоненте её пикселя, просто преобразовывая тип в byte. Это позволит поддерживать до 256 типов рельефа, чего нам будет вполне достаточно.

  public void RefreshTerrain (HexCell cell) {
                cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
        }


Чтобы применить данные к текстуре и передать её в GPU, нам нужно вызывать Texture2D.SetPixels32, а затем Texture2D.Apply. Как и в случае с фрагментами, мы отложим эти операции на LateUpdate, чтобы можно было выполнять их не чаще раза за кадр, вне зависимости от количества изменившихся ячеек.

  public void RefreshTerrain (HexCell cell) {
                cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
                enabled = true;
        }
        
        void LateUpdate () {
                cellTexture.SetPixels32(cellTextureData);
                cellTexture.Apply();
                enabled = false;
        }


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

  public void Initialize (int x, int z) {
                …
                enabled = true;
        }


Триангуляция индексов ячеек


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

Удалим из HexMesh устаревшие общие поля useColors и useTerrainTypes. Заменим их одним полем useCellData.

//        public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates;
//      public bool useTerrainTypes;
        public bool useCollider, useCellData, useUVCoordinates, useUV2Coordinates;


Выполним рефакторинг-переименование списка terrainTypes в cellIndices. Давайте также рефакторим-переименуем colors в cellWeights — это имя подойдёт больше.

//        [NonSerialized] List vertices, terrainTypes;
//      [NonSerialized] List colors;
        [NonSerialized] List vertices, cellIndices;
        [NonSerialized] List cellWeights;
        [NonSerialized] List uvs, uv2s;
        [NonSerialized] List triangles;


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

  public void Clear () {
                hexMesh.Clear();
                vertices = ListPool.Get();
                if (useCellData) {
                        cellWeights = ListPool.Get();
                        cellIndices = ListPool.Get();
                }
//              if (useColors) {
//                      colors = ListPool.Get();
//              }
                if (useUVCoordinates) {
                        uvs = ListPool.Get();
                }
                if (useUV2Coordinates) {
                        uv2s = ListPool.Get();
                }
//              if (useTerrainTypes) {
//                      terrainTypes = ListPool.Get();
//              }
                triangles = ListPool.Get();
        }


Выполним такое же группирование в Apply.

  public void Apply () {
                hexMesh.SetVertices(vertices);
                ListPool.Add(vertices);
                if (useCellData) {
                        hexMesh.SetColors(cellWeights);
                        ListPool.Add(cellWeights);
                        hexMesh.SetUVs(2, cellIndices);
                        ListPool.Add(cellIndices);
                }
//              if (useColors) {
//                      hexMesh.SetColors(colors);
//                      ListPool.Add(colors);
//              }
                if (useUVCoordinates) {
                        hexMesh.SetUVs(0, uvs);
                        ListPool.Add(uvs);
                }
                if (useUV2Coordinates) {
                        hexMesh.SetUVs(1, uv2s);
                        ListPool.Add(uv2s);
                }
//              if (useTerrainTypes) {
//                      hexMesh.SetUVs(2, terrainTypes);
//                      ListPool.Add(terrainTypes);
//              }
                hexMesh.SetTriangles(triangles, 0);
                ListPool.Add(triangles);
                hexMesh.RecalculateNormals();
                if (useCollider) {
                        meshCollider.sharedMesh = hexMesh;
                }
        }


Удалим все методы AddTriangleColor и AddTriangleTerrainTypes. Заменим их соответствующими методами AddTriangleCellData, которые добавляют индексы и веса за один раз.

  public void AddTriangleCellData (
                Vector3 indices, Color weights1, Color weights2, Color weights3
        ) {
                cellIndices.Add(indices);
                cellIndices.Add(indices);
                cellIndices.Add(indices);
                cellWeights.Add(weights1);
                cellWeights.Add(weights2);
                cellWeights.Add(weights3);
        }
                
        public void AddTriangleCellData (Vector3 indices, Color weights) {
                AddTriangleCellData(indices, weights, weights, weights);
        }


Сделаем то же самое в соответствующих метода AddQuad.

  public void AddQuadCellData (
                Vector3 indices,
                Color weights1, Color weights2, Color weights3, Color weights4
        ) {
                cellIndices.Add(indices);
                cellIndices.Add(indices);
                cellIndices.Add(indices);
                cellIndices.Add(indices);
                cellWeights.Add(weights1);
                cellWeights.Add(weights2);
                cellWeights.Add(weights3);
                cellWeights.Add(weights4);
        }

        public void AddQuadCellData (
                Vector3 indices, Color weights1, Color weights2
        ) {
                AddQuadCellData(indices, weights1, weights1, weights2, weights2);
        }

        public void AddQuadCellData (Vector3 indices, Color weights) {
                AddQuadCellData(indices, weights, weights, weights, weights);
        }


Рефакторинг HexGridChunk


На данном этапе мы получаем в HexGridChunk множество ошибок компилятора, которые нужно устранить. Но сначала ради согласованности рефакторим-переименуем статические цвета в веса.

  static Color weights1 = new Color(1f, 0f, 0f);
        static Color weights2 = new Color(0f, 1f, 0f);
        static Color weights3 = new Color(0f, 0f, 1f);


Давайте начнём с исправления TriangulateEdgeFan. Раньше ему требовался тип, а теперь нужен индекс ячейки. Заменим код AddTriangleColor и AddTriangleTerrainTypes соответствующим кодом AddTriangleCellData.

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float index) {
                terrain.AddTriangle(center, edge.v1, edge.v2);
                terrain.AddTriangle(center, edge.v2, edge.v3);
                terrain.AddTriangle(center, edge.v3, edge.v4);
                terrain.AddTriangle(center, edge.v4, edge.v5);

                Vector3 indices;
                indices.x = indices.y = indices.z = index;
                terrain.AddTriangleCellData(indices, weights1);
                terrain.AddTriangleCellData(indices, weights1);
                terrain.AddTriangleCellData(indices, weights1);
                terrain.AddTriangleCellData(indices, weights1);

//              terrain.AddTriangleColor(weights1);
//              terrain.AddTriangleColor(weights1);
//              terrain.AddTriangleColor(weights1);
//              terrain.AddTriangleColor(weights1);

//              Vector3 types;
//              types.x = types.y = types.z = type;
//              terrain.AddTriangleTerrainTypes(types);
//              terrain.AddTriangleTerrainTypes(types);
//              terrain.AddTriangleTerrainTypes(types);
//              terrain.AddTriangleTerrainTypes(types);
        }


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

          TriangulateEdgeFan(center, e, cell.Index);


Далее TriangulateEdgeStrip. Здесь всё немного сложнее, но используем тот же подход. Также рефакторим-переименуем имена параметров c1 и c2 в w1 и w2.

  void TriangulateEdgeStrip (
                EdgeVertices e1, Color w1, float index1,
                EdgeVertices e2, Color w2, float index2,
                bool hasRoad = false
        ) {
                terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
                terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
                terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
                terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);

                Vector3 indices;
                indices.x = indices.z = index1;
                indices.y = index2;
                terrain.AddQuadCellData(indices, w1, w2);
                terrain.AddQuadCellData(indices, w1, w2);
                terrain.AddQuadCellData(indices, w1, w2);
                terrain.AddQuadCellData(indices, w1, w2);

//              terrain.AddQuadColor(c1, c2);
//              terrain.AddQuadColor(c1, c2);
//              terrain.AddQuadColor(c1, c2);
//              terrain.AddQuadColor(c1, c2);

//              Vector3 types;
//              types.x = types.z = type1;
//              types.y = type2;
//              terrain.AddQuadTerrainTypes(types);
//              terrain.AddQuadTerrainTypes(types);
//              terrain.AddQuadTerrainTypes(types);
//              terrain.AddQuadTerrainTypes(types);

                if (hasRoad) {
                        TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4);
                }
        }


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

          TriangulateEdgeStrip(
                        m, weights1, cell.Index,
                        e, weights1, cell.Index
                );
                
        …
                
                        TriangulateEdgeStrip(
                                e1, weights1, cell.Index,
                                e2, weights2, neighbor.Index, hasRoad
                        );
        
        …
        
        void TriangulateEdgeTerraces (
                EdgeVertices begin, HexCell beginCell,
                EdgeVertices end, HexCell endCell,
                bool hasRoad
        ) {
                EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
                Color w2 = HexMetrics.TerraceLerp(weights1, weights2, 1);
                float i1 = beginCell.Index;
                float i2 = endCell.Index;

                TriangulateEdgeStrip(begin, weights1, i1, e2, w2, i2, hasRoad);

                for (int i = 2; i < HexMetrics.terraceSteps; i++) {
                        EdgeVertices e1 = e2;
                        Color w1 = w2;
                        e2 = EdgeVertices.TerraceLerp(begin, end, i);
                        w2 = HexMetrics.TerraceLerp(weights1, weights2, i);
                        TriangulateEdgeStrip(e1, w1, i1, e2, w2, i2, hasRoad);
                }

                TriangulateEdgeStrip(e2, w2, i1, end, weights2, i2, hasRoad);
        }


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

  void TriangulateCorner (
                Vector3 bottom, HexCell bottomCell,
                Vector3 left, HexCell leftCell,
                Vector3 right, HexCell rightCell
        ) {
                …
                else {
                        terrain.AddTriangle(bottom, left, right);
                        Vector3 indices;
                        indices.x = bottomCell.Index;
                        indices.y = leftCell.Index;
                        indices.z = rightCell.Index;
                        terrain.AddTriangleCellData(indices, weights1, weights2, weights3);
//                      terrain.AddTriangleColor(weights1, weights2, weights3);
//                      Vector3 types;
//                      types.x = bottomCell.TerrainTypeIndex;
//                      types.y = leftCell.TerrainTypeIndex;
//                      types.z = rightCell.TerrainTypeIndex;
//                      terrain.AddTriangleTerrainTypes(types);
                }

                features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
        }


Далее в TriangulateCornerTerraces.

  void TriangulateCornerTerraces (
                Vector3 begin, HexCell beginCell,
                Vector3 left, HexCell leftCell,
                Vector3 right, HexCell rightCell
        ) {
                Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
                Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
                Color w3 = HexMetrics.TerraceLerp(weights1, weights2, 1);
                Color w4 = HexMetrics.TerraceLerp(weights1, weights3, 1);
                Vector3 indices;
                indices.x = beginCell.Index;
                indices.y = leftCell.Index;
                indices.z = rightCell.Index;

                terrain.AddTriangle(begin, v3, v4);
                terrain.AddTriangleCellData(indices, weights1, w3, w4);
//              terrain.AddTriangleColor(weights1, w3, w4);
//              terrain.AddTriangleTerrainTypes(indices);

                for (int i = 2; i < HexMetrics.terraceSteps; i++) {
                        Vector3 v1 = v3;
                        Vector3 v2 = v4;
                        Color w1 = w3;
                        Color w2 = w4;
                        v3 = HexMetrics.TerraceLerp(begin, left, i);
                        v4 = HexMetrics.TerraceLerp(begin, right, i);
                        w3 = HexMetrics.TerraceLerp(weights1, weights2, i);
                        w4 = HexMetrics.TerraceLerp(weights1, weights3, i);
                        terrain.AddQuad(v1, v2, v3, v4);
                        terrain.AddQuadCellData(indices, w1, w2, w3, w4);
//                      terrain.AddQuadColor(w1, w2, w3, w4);
//                      terrain.AddQuadTerrainTypes(indices);
                }

                terrain.AddQuad(v3, v4, left, right);
                terrain.AddQuadCellData(indices, w3, w4, weights2, weights3);
//              terrain.AddQuadColor(w3, w4, weights2, weights3);
//              terrain.AddQuadTerrainTypes(indices);
        }


Затем в TriangulateCornerTerracesCliff.

  void TriangulateCornerTerracesCliff (
                Vector3 begin, HexCell beginCell,
                Vector3 left, HexCell leftCell,
                Vector3 right, HexCell rightCell
        ) {
                float b = 1f / (rightCell.Elevation - beginCell.Elevation);
                if (b < 0) {
                        b = -b;
                }
                Vector3 boundary = Vector3.Lerp(
                        HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b
                );
                Color boundaryWeights = Color.Lerp(weights1, weights3, b);
                Vector3 indices;
                indices.x = beginCell.Index;
                indices.y = leftCell.Index;
                indices.z = rightCell.Index;

                TriangulateBoundaryTriangle(
                        begin, weights1, left, weights2, boundary, boundaryWeights, indices
                );

                if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
                        TriangulateBoundaryTriangle(
                                left, weights2, right, weights3,
                                boundary, boundaryWeights, indices
                        );
                }
                else {
                        terrain.AddTriangleUnperturbed(
                                HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
                        );
                        terrain.AddTriangleCellData(
                                indices, weights2, weights3, boundaryWeights
                        );
//                      terrain.AddTriangleColor(weights2, weights3, boundaryColor);
//                      terrain.AddTriangleTerrainTypes(indices);
                }
        }


И немного иначе в TriangulateCornerCliffTerraces.

  void TriangulateCornerCliffTerraces (
                Vector3 begin, HexCell beginCell,
                Vector3 left, HexCell leftCell,
                Vector3 right, HexCell rightCell
        ) {
                float b = 1f / (leftCell.Elevation - beginCell.Elevation);
                if (b < 0) {
                        b = -b;
                }
                Vector3 boundary = Vector3.Lerp(
                        HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b
                );
                Color boundaryWeights = Color.Lerp(weights1, weights2, b);
                Vector3 indices;
                indices.x = beginCell.Index;
                indices.y = leftCell.Index;
                indices.z = rightCell.Index;

                TriangulateBoundaryTriangle(
                        right, weights3, begin, weights1, boundary, boundaryWeights, indices
                );

                if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
                        TriangulateBoundaryTriangle(
                                left, weights2, right, weights3,
                                boundary, boundaryWeights, indices
                        );
                }
                else {
                        terrain.AddTriangleUnperturbed(
                                HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
                        );
                        terrain.AddTriangleCellData(
                                indices, weights2, weights3, boundaryWeights
                        );
//                      terrain.AddTriangleColor(weights2, weights3, boundaryWeights);
//                      terrain.AddTriangleTerrainTypes(indices);
                }
        }


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

  void TriangulateBoundaryTriangle (
                Vector3 begin, Color beginWeights,
                Vector3 left, Color leftWeights,
                Vector3 boundary, Color boundaryWeights, Vector3 indices
        ) {
                Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
                Color w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, 1);

                terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
                terrain.AddTriangleCellData(indices, beginWeights, w2, boundaryWeights);
//              terrain.AddTriangleColor(beginColor, c2, boundaryColor);
//              terrain.AddTriangleTerrainTypes(types);

                for (int i = 2; i < HexMetrics.terraceSteps; i++) {
                        Vector3 v1 = v2;
                        Color w1 = w2;
                        v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
                        w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, i);
                        terrain.AddTriangleUnperturbed(v1, v2, boundary);
                        terrain.AddTriangleCellData(indices, w1, w2, boundaryWeights);
//                      terrain.AddTriangleColor(c1, c2, boundaryColor);
//                      terrain.AddTriangleTerrainTypes(types);
                }

                terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
                terrain.AddTriangleCellData(indices, w2, leftWeights, boundaryWeights);
//              terrain.AddTriangleColor(c2, leftColor, boundaryColor);
//              terrain.AddTriangleTerrainTypes(types);
        }


Последний метод, который требует изменений — TriangulateWithRiver.

  void TriangulateWithRiver (
                HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
        ) {
                …

                terrain.AddTriangle(centerL, m.v1, m.v2);
                terrain.AddQuad(centerL, center, m.v2, m.v3);
                terrain.AddQuad(center, centerR, m.v3, m.v4);
                terrain.AddTriangle(centerR, m.v4, m.v5);

                Vector3 indices;
                indices.x = indices.y = indices.z = cell.Index;
                terrain.AddTriangleCellData(indices, weights1);
                terrain.AddQuadCellData(indices, weights1);
                terrain.AddQuadCellData(indices, weights1);
                terrain.AddTriangleCellData(indices, weights1);

//              terrain.AddTriangleColor(weights1);
//              terrain.AddQuadColor(weights1);
//              terrain.AddQuadColor(weights1);
//              terrain.AddTriangleColor(weights1);

//              Vector3 types;
//              types.x = types.y = types.z = cell.TerrainTypeIndex;
//              terrain.AddTriangleTerrainTypes(types);
//              terrain.AddQuadTerrainTypes(types);
//              terrain.AddQuadTerrainTypes(types);
//              terrain.AddTriangleTerrainTypes(types);

                …
        }


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

919b8c7317b2aa3c06f1077eef27fe96.png


Рельеф использует данные ячеек.

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

8a5a23ce850af789e759dde6879f93eb.png


Использование индексов ячеек как индексов текстур рельефа.

Не могу заставить работать отрефакторенный код. Что я делаю не так?

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


Передача данных ячеек в шейдер


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

После создания текстуры ячейки вызовем статический метод Shader.SetGlobalTexture, чтобы сделать её глобально видимой как _HexCellData.

  public void Initialize (int x, int z) {
                …
                else {
                        cellTexture = new Texture2D(
                                x, z, TextureFormat.RGBA32, false, true
                        );
                        cellTexture.filterMode = FilterMode.Point;
                        cellTexture.wrapMode = TextureWrapMode.Clamp;
                        Shader.SetGlobalTexture("_HexCellData", cellTexture);
                }

                …
        }


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

          else {
                        cellTexture = new Texture2D(
                                x, z, TextureFormat.RGBA32, false, true
                        );
                        cellTexture.filterMode = FilterMode.Point;
                        cellTexture.wrapMode = TextureWrapMode.Clamp;
                        Shader.SetGlobalTexture("_HexCellData", cellTexture);
                }
                Shader.SetGlobalVector(
                        "_HexCellData_TexelSize",
                        new Vector4(1f / x, 1f / z, x, z)
                );


Доступ к данным шейдера


Создадим в папке материалов новый include-файл шейдера под названием HexCellData. Внутри него определим переменные для информации о текстуре и размере данных ячеек. Также создадим функцию для получения данных ячеек для заданных данных меша вершины.

sampler2D _HexCellData;
float4 _HexCellData_TexelSize;

float4 GetCellData (appdata_full v) {
}


a64604dbc7d3ad5ff1e1578bd0c84eec.png


Новый include-файл.

Индексы ячеек хранятся в v.texcoord2, как это было и с типами рельефа. Давайте начнём с первого индекса — v.texcoord2.x. К сожалению, мы не можем напрямую использовать индекс для сэмплирования текстуры данных ячеек. Нам придётся преобразовать его в UV-координаты.

Первый этап создания координаты U — деление индекса ячейки на ширину текстуры. Мы можем это сделать, умножив его на _HexCellData_TexelSize.x.

float4 GetCellData (appdata_full v) {
        float2 uv;
        uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
}


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

float4 GetCellData (appdata_full v) {
        float2 uv;
        uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
        float row = floor(uv.x);
        uv.x -= row;
}


Координата V находится делением строки на высоту текстуры.

float4 GetCellData (appdata_full v) {
        float2 uv;
        uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
        float row = floor(uv.x);
        uv.x -= row;
        uv.y = row * _HexCellData_TexelSize.y;
}


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

float4 GetCellData (appdata_full v) {
        float2 uv;
        uv.x = (v.texcoord2.x + 0.5) * _HexCellData_TexelSize.x;
        float row = floor(uv.x);
        uv.x -= row;
        uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}


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

float4 GetCellData (appdata_full v, int index) {
        float2 uv;
        uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
        float row = floor(uv.x);
        uv.x -= row;
        uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}


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

float4 GetCellData (appdata_full v, int index) {
        float2 uv;
        uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
        float row = floor(uv.x);
        uv.x -= row;
        uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
        float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
}


Четвёртый компонент данных содержит индекс типа рельефа, который мы храним непосредственно как байт. Однако GPU автоматически конвертировал его в значение с плавающей запятой в интервале 0–1. Чтобы преобразовать его обратно в верное значение, умножим его на 255. После этого можно вернуть данные.

     float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
        data.w *= 255;
        return data;


Чтобы воспользоваться этим функционалом, включим HexCellData в шейдер Terrain. Так как я поместил этот шейдер в Materials / Terrain, то нужно использовать относительный путь …/HexCellData.cginc.

             #include "../HexCellData.cginc"

                UNITY_DECLARE_TEX2DARRAY(_MainTex);


В вершинной программе получим данные ячеек для всех трёх индексов ячейки, хранящихся в данных вершины. Затем присвоим data.terrain их индексы рельефа.

             void vert (inout appdata_full v, out Input data) {
                        UNITY_INITIALIZE_OUTPUT(Input, data);
//                      data.terrain = v.texcoord2.xyz;

                        float4 cell0 = GetCellData(v, 0);
                        float4 cell1 = GetCellData(v, 1);
                        float4 cell2 = GetCellData(v, 2);

                        data.terrain.x = cell0.w;
                        data.terrain.y = cell1.w;
                        data.terrain.z = cell2.w;
                }


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

unitypackage

Видимость


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

Шейдер


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

             struct Input {
                        float4 color : COLOR;
                        float3 worldPos;
                        float3 terrain;
                        float3 visibility;
                };


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

             void vert (inout appdata_full v, out Input data) {
                        UNITY_INITIALIZE_OUTPUT(Input, data);

                        float4 cell0 = GetCellData(v, 0);
                        float4 cell1 = GetCellData(v, 1);
                        float4 cell2 = GetCellData(v, 2);

                        data.terrain.x = cell0.w;
                        data.terrain.y = cell1.w;
                        data.terrain.z = cell2.w;

                        data.visibility.x = cell0.x;
                        data.visibility.y = cell1.x;
                        data.visibility.z = cell2.x;
                }


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

             float4 GetTerrainColor (Input IN, int index) {
                        float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]);
                        float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw);
                        return c * (IN.color[index] * IN.visibility[index]);
                }


1bf183e695a42f4691f63cec72255993.png


Ячейки стали чёрными.

Разве не можем мы вместо этого комбинировать видимость в вершинной программе?

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


Полная темнота — это перебор для временно невидимых ячеек. Чтобы мы всё-таки могли видеть рельеф, нужно увеличить показатель, используемый для скрытых ячеек. Давайте перейдём от 0–1 к ¼–1, что можно сделать, воспользовавшись функцией lerp в конце вершинной программы.

             void vert (inout appdata_full v, out Input data) {
                        …

                        data.visibility.x = cell0.x;
                        data.visibility.y = cell1.x;
                        data.visibility.z = cell2.x;
                        data.visibility = lerp(0.25, 1, data.visibility);
                }


a200903e2f31f3bdcd0c996d42f91a02.png


Затемнённые ячейки.

Отслеживание видимости ячеек


Чтобы видимость работала, ячейки должны отслеживать свою видимость. Но как ячейка определит, видима ли она? Мы можем сделать это, отслеживая количество видящих её сущностей. Когда кто-то начинает видеть ячейку, он должен сообщить об этом ячейке. А когда кто-то перестаёт видеть ячейку, он тоже должен уведомить её об этом. Ячейка просто отслеживает количество смотрящих, какими бы ни были эти сущности. Если ячейка имеет величину видимости не менее 1, то она видима, в противном случае — невидима. Чтобы реализовать такое поведение, добавим в HexCell переменную, два метода и свойство.

  public bool IsVisible {
                get {
                        return visibility > 0;
                }
        }

        …

        int visibility;

        …

        public void IncreaseVisibility () {
                visibility += 1;
        }

        public void DecreaseVisibility () {
                visibility -= 1;
        }


Далее добавим в HexCellShaderData метод RefreshVisibility, который делает то же самое, что и RefreshTerrain, только для видимости. Сохраним данные в компоненте R данных ячеек. Так как мы работаем с байтами, которые преобразуются в значения 0–1, для обозначения видимости используем (byte)255.

  public void RefreshVisibility (HexCell cell) {
                cellTextureData[cell.Index].r = cell.IsVisible ? (byte)255 : (byte)0;
                enabled = true;
        }


Будем вызывать этот метод при увеличении и уменьшении видимости, меняя значение между 0 и 1.

  public void IncreaseVisibility () {
                visibility += 1;
                if (visibility == 1) {
                        ShaderData.RefreshVisibility(this);
                }
        }

        public void DecreaseVisibility () {
                visibility -= 1;
                if (visibility == 0) {
                        ShaderData.RefreshVisibility(this);
                }
        }


Создание области видимости отрядов


Давайте сделаем так, чтобы отряды могли видеть занимаемую ими ячейку. Это реализуется с помощью вызова IncreaseVisibility для нового местоположения отряда при задании HexUnit.Location. Также вызовем для ста

© Habrahabr.ru