12 мая в 14:03

Unity3D. Балуемся с мешем. Часть 1 — Генерация меша с помощью карты высот tutorial

Давным давно я работал с Unity3D. Эти времена прошли, но в памяти остались и тёплые, и не очень воспоминания.


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



Содержание


  1. Unity3D. Балуемся с мешем. Часть 1 — Генерация меша с помощью карты высот
  2. Unity3D. Балуемся с мешем. Часть 2 — Деформация меша с помощью карты высот
  3. Unity3D. Балуемся с мешем. Часть 3 — Деформация меша, основанная на коллизиях

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


Что такое меш


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


В Unity меш имеет:


  • vertices — массив координат вершин меша
  • triangles — массив данных о том, как эти вершины между собой соединены (проще — треугольники)
  • normals — массив данных того, куда каждая вершина смотрит (каждая нормаль перпендикулярна вершине)
  • uv — текстурные координаты
  • colors — массив цветов вершин (никогда не работал с ними)
  • tangents — касательные к каждой вершине (нужны для карт рельефа — bump map shading)

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


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


Что такое карта высот



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


Теория


Как же мы можем построить сам меш, используя карту высот? Получается, что мы имеем рисунок, цвет пикселя которого отвечает за высоту вершины. В теории мы должны пройтись по каждому пикселю карты высот и спросить какого цвета она, конвертировать цвет пикселя во float от 0 до 1 и преобразовать полученные данные в меш в соответствии с указанными размерами.


В этом нам помогут GetPixel и Color.grayscale. Учитывайте, что на текстуре должна быть галочка Read&Write, иначе прочитать данные не получится.


Базовые приготовления


С тем, что такое меш мы познакомились, пора приступать к работе. Чтобы не засорять код, создадим класс ParentEditor. В него мы засунем всякие вспомогательные методы и от него будем наследовать наши скрипты расширения. По задумке у нас будут окна редактора, поэтому сам класс будет наследоваться от EditorWindow.


Думаю, тут пояснения не нужны
using UnityEditor;
using UnityEngine;

public class ParentEditor : EditorWindow
{
    public void CreatePaths()
    {
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools")) AssetDatabase.CreateFolder("Assets", "MeshTools");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Meshes")) AssetDatabase.CreateFolder("Assets/MeshTools", "Meshes");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Meshes/Generated")) AssetDatabase.CreateFolder("Assets/MeshTools/Meshes", "Generated");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Meshes/Updated")) AssetDatabase.CreateFolder("Assets/MeshTools/Meshes", "Updated");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Prefabs")) AssetDatabase.CreateFolder("Assets/MeshTools", "Prefabs");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Prefabs/Generated")) AssetDatabase.CreateFolder("Assets/MeshTools/Prefabs", "Generated");
        if (!AssetDatabase.IsValidFolder("Assets/MeshTools/Prefabs/Updated")) AssetDatabase.CreateFolder("Assets/MeshTools/Prefabs", "Updated");
    }

    public int Cut(int value)
    {
        return Mathf.Min(value, 255);
    }

    public Mesh CopyMesh(Mesh mesh)
    {
        Mesh newmesh = new Mesh();
        newmesh.vertices = mesh.vertices;
        newmesh.triangles = mesh.triangles;
        newmesh.uv = mesh.uv;
        newmesh.normals = mesh.normals;
        newmesh.tangents = mesh.tangents;
        return newmesh;
    }
}

Отлично! Переходим к интересному...


Генерация меша


Создадим новый класс MeshGenerator и унаследуем его от ParentEditor.


Обозначим в нём несколько нужных нам переменных, сделаем их отображаемыми и укажем путь в панели инструментов:


MeshGenerator
using UnityEditor;
using UnityEngine;

public class MeshGenerator : ParentEditor
{
    public Texture2D heightMap;
    public Material mat;
    public Vector3 size = new Vector3(2048, 300, 2048);
    public string mname = "Enter name";
    GameObject generated;
    [MenuItem("Tools/Mesh Generator")]
    static void Init()
    {
        MeshGenerator mg = (MeshGenerator)GetWindow(typeof(MeshGenerator));
        mg.Show();
    }

     void OnGUI()
    {
        heightMap = (Texture2D)EditorGUILayout.ObjectField("Height map", heightMap, typeof(Texture2D), false);
        mat = (Material)EditorGUILayout.ObjectField("Material", mat, typeof(Material), false);
        size = EditorGUILayout.Vector3Field("Size", size);
        mname = EditorGUILayout.TextField("Name", mname);
}

Давайте теперь напишем метод, который будет генерировать нам меш и возвращать полноценный GO


Generate
    GameObject Generate()
    {
        // Создаём GO и вешаем всё, что необходимо
        GameObject go = new GameObject(mname);
        go.transform.position = Vector3.zero;
        go.AddComponent<MeshFilter>();
        go.AddComponent<MeshRenderer>();

        // Проверяем, что мы добавили материал. Если не добавили - прерываем выполнение
        if (mat != null) go.GetComponent<Renderer>().material = mat;
        else
        {
            Debug.LogError("No material attached! Aborting");
            return null;
        }

        // Вспоминаем про проблему Unity и поэтому
        // Возвращаем меньшее между стороной карты высот и 255
        // 255*255 = 65025
        int width = Cut(heightMap.width);
        int height = Cut(heightMap.height);

        // Создаём меш
        Mesh mesh = new Mesh();

        Vector3[] vertices = new Vector3[height * width]; // создаём массив вершин
        Vector2[] UVs = new Vector2[height * width]; // uv координат
        Vector4[] tangs = new Vector4[height * width]; // массив касательных

        // создаём множители размера так сказать
        Vector2 uvScale = new Vector2(1 / (width - 1), 1 / (height - 1));
        Vector3 sizeScale = new Vector3(size.x / (width - 1), size.y, size.z / (height - 1)); 
                                          // важно понимать, что текстура у нас Vector2 (x, y)
                                         // но меш у нас Vector3(x, y, z). Поэтому высота
                                        // карты высот отвечает за z координату

        int index;
        float pixelHeight;
        for (int y = 0; y < height; y++)
{
            for (int x = 0; x < width; x++)
            {
                index = y * width + x;  // получаем индекс

                pixelHeight = heightMap.GetPixel(x, y).grayscale; // получаем высоту пикселя на карте высот
                Vector3 vertex = new Vector3(x, pixelHeight, y); // задаём вершину
                vertices[index] = Vector3.Scale(sizeScale, vertex); // вкладываем вершину в массив и соотносим по размерам
                Vector2 cur_uv = new Vector2(x, y); // задаём uv координату
                UVs[index] = Vector2.Scale(cur_uv, uvScale); // вкладываем uv координату в массив и соотносим по размерам

                /*
                Vector*.Scale(Vector* a, Vector* b) перемножает вектора по координатам соответственно.
                То есть, например, Vector3.Scale(a=(1, 2, 1), b=(2, 3, 1)) вернёт новый вектор (2, 6, 1)
                */

                /* Расчитываем касательную: этот вектор идёт с предыдущей вершины
                 к следующей вдоль оси X. Вектор касательной нужен нам в том случае, если мы
                 будем применять отражающие шейдеры к мешу.
                 W координата касательной всегда должна быть равна либо -1, либо 1, так как
                 бинормаль расчитывается путём умножения нормали на W координату касательной */
                Vector3 leftV = new Vector3(x - 1, heightMap.GetPixel(x - 1, y).grayscale, y);
                Vector3 rightV = new Vector3(x + 1, heightMap.GetPixel(x + 1, y).grayscale, y);
                Vector3 tang = Vector3.Scale(sizeScale, rightV - leftV).normalized;
                tangs[index] = new Vector4(tang.x, tang.y, tang.z, 1);
            }
        }

        // Наконец первые изменения в меше
        mesh.vertices = vertices; // назначаем вершины
        mesh.uv = UVs; // назначаем uv координаты

        // Создаём те самые треугольники
        index = 0;
        int[] triangles = new int[(height - 1) * (width - 1) * 6];
        for (int y = 0; y < height - 1; y++)
        {
            for (int x = 0; x < width - 1; x++)
            {
                // создаём полигон
                triangles[index++] = (y * width) + x;
                triangles[index++] = ((y + 1) * width) + x;
                triangles[index++] = (y * width) + x + 1;

                triangles[index++] = ((y + 1) * width) + x;
                triangles[index++] = ((y + 1) * width) + x + 1;
                triangles[index++] = (y * width) + x + 1;
            }
        }
        // Даём знать как вершины соединены между собой
        mesh.triangles = triangles;

        // Авторасчёт нормалей, основываясь на меше
        mesh.RecalculateNormals();

        // Касательные нужно назначать обязательно после расчёта или перерасчёта нормалей
        mesh.tangents = tangs;

        // Не забываем указать меш
        go.GetComponent<MeshFilter>().sharedMesh = mesh;
        return go;
    }

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


Новый OnGUI
    void OnGUI()
    {
        heightMap = (Texture2D)EditorGUILayout.ObjectField("Height map", heightMap, typeof(Texture2D), false);
        mat = (Material)EditorGUILayout.ObjectField("Material", mat, typeof(Material), false);
        size = EditorGUILayout.Vector3Field("Size", size);
        mname = EditorGUILayout.TextField("Name", mname);

        if (GUILayout.Button("Generate mesh", GUILayout.Height(20))) generated = Generate();

        if (GUILayout.Button("Save", GUILayout.Height(20)))
        {
            Mesh generated_mesh = generated.GetComponent<MeshFilter>().sharedMesh;
            CreatePaths();
            AssetDatabase.CreateAsset(generated_mesh, "Assets/MeshTools/Meshes/Generated/" + mname + ".asset"); // сохраняем меш
            PrefabUtility.CreatePrefab("Assets/MeshTools/Prefabs/Generated/" + mname + ".prefab", generated); // сохраняем префаб нашего GO
        }
    }

Если вы всё делали правильно, то у вас появился новый выпадающий пункт меню Tools->Mesh Generator и вы уже можете тестировать своё самописное расширение редактора вставляя туда карты высот и материалы! :)


Если у вас что-то не получилось, проверьте, что вы создали класс ParentEditor, а сам ваш класс выглядит следующим образом:


MeshGenerator.cs
using UnityEditor;
using UnityEngine;

public class MeshGenerator : ParentEditor
{
    public Texture2D heightMap;
    public Material mat;
    public Vector3 size = new Vector3(2048, 300, 2048);
    public string mname = "Enter name";
    GameObject generated;
    [MenuItem("Tools/Mesh Generator")]
    static void Init()
    {
        MeshGenerator mg = (MeshGenerator)GetWindow(typeof(MeshGenerator));
        mg.Show();
    }

    void OnGUI()
    {
        heightMap = (Texture2D)EditorGUILayout.ObjectField("Height map", heightMap, typeof(Texture2D), false);
        mat = (Material)EditorGUILayout.ObjectField("Material", mat, typeof(Material), false);
        size = EditorGUILayout.Vector3Field("Size", size);
        mname = EditorGUILayout.TextField("Name", mname);

        if (GUILayout.Button("Generate mesh", GUILayout.Height(20))) generated = Generate();

        if (GUILayout.Button("Save", GUILayout.Height(20)))
        {
            Mesh generated_mesh = generated.GetComponent<MeshFilter>().sharedMesh;
            CreatePaths();
            AssetDatabase.CreateAsset(generated_mesh, "Assets/MeshTools/Meshes/Generated/" + mname + ".asset");
            PrefabUtility.CreatePrefab("Assets/MeshTools/Prefabs/Generated/" + mname + ".prefab", generated);
        }
    }

    GameObject Generate()
    {
        GameObject go = new GameObject(mname);
        go.transform.position = Vector3.zero;
        go.AddComponent<MeshFilter>();
        go.AddComponent<MeshRenderer>();

        if (mat != null) go.GetComponent<Renderer>().material = mat;
        else
        {
            Debug.LogError("No material attached! Aborting");
            return null;
        }

        int width = Cut(heightMap.width);
        int height = Cut(heightMap.height);

        Mesh mesh = new Mesh();

        Vector3[] vertices = new Vector3[height * width];
        Vector2[] UVs = new Vector2[height * width];
        Vector4[] tangs = new Vector4[height * width];

        Vector2 uvScale = new Vector2(1 / (width - 1), 1 / (height - 1));
        Vector3 sizeScale = new Vector3(size.x / (width - 1), size.y, size.z / (height - 1));

        int index;
        float pixelHeight;
        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                index = y * width + x;

                pixelHeight = heightMap.GetPixel(x, y).grayscale;
                Vector3 vertex = new Vector3(x, pixelHeight, y);
                vertices[index] = Vector3.Scale(sizeScale, vertex);
                Vector2 cur_uv = new Vector2(x, y);
                UVs[index] = Vector2.Scale(cur_uv, uvScale); 

                Vector3 leftV = new Vector3(x - 1, heightMap.GetPixel(x - 1, y).grayscale, y);
                Vector3 rightV = new Vector3(x + 1, heightMap.GetPixel(x + 1, y).grayscale, y);
                Vector3 tang = Vector3.Scale(sizeScale, rightV - leftV).normalized;
                tangs[index] = new Vector4(tang.x, tang.y, tang.z, 1);
            }
        }

        mesh.vertices = vertices;
        mesh.uv = UVs;

        index = 0;
        int[] triangles = new int[(height - 1) * (width - 1) * 6];
        for (int y = 0; y < height - 1; y++)
        {
            for (int x = 0; x < width - 1; x++)
            {
                triangles[index++] = (y * width) + x;
                triangles[index++] = ((y + 1) * width) + x;
                triangles[index++] = (y * width) + x + 1;

                triangles[index++] = ((y + 1) * width) + x;
                triangles[index++] = ((y + 1) * width) + x + 1;
                triangles[index++] = (y * width) + x + 1;
            }
        }

        mesh.triangles = triangles;
        mesh.RecalculateNormals();
        mesh.tangents = tangs;
        go.GetComponent<MeshFilter>().sharedMesh = mesh;
        return go;
    }
}

Тестируем наше расширение

Возьмём карту высот и укажем её MaxSize в 256, поставим галочку Read&Write и нажмём на Apply.


Откроем наше расширение и зададим параметры. Выбираем Tools->Mesh Generator
И видим следующее окно (в моём случае окно уже с настройками):

Нажимаем на Generate Mesh и...



Отображение Shaded and Wireframe



Использованная карта высот


Нажав на кнопку Save мы сохраним сгенерированный меш и префаб объекта в папках "Assets/MeshTools/Meshes/Generated" и "Assets/MeshTools/Prefabs/Generated" соответственно.


Шалость удалась! :)


Продолжить баловаться...

Нужна ли вторая часть про деформацию меша с помощью карты высот?
93%
(50)
Хочу побаловаться ещё!
7%
(4)
Мне эти приколы неинтересны
0%
(0)
Не стоит

Проголосовало 54 человека. Воздержалось 11 человек.

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

Kit Scribe @KitScribe
карма
9,0
рейтинг 46,4
Хаброжитель
Похожие публикации
Самое читаемое Разработка

Комментарии (4)

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

    По-моему я эту игру даже купил :)

    • 0
      Интересно, это правда No Man's Sky.
  • +2
    Рекомендую поизучать исходники ассета Etherea в котором автор использует GPU для генерации карт высот. Я в своё время начал разбираться в юнити именно с целью понять, как устроен этот чудо генератор планет.

    • 0

      Это действительно интересно, спасибо!


      К сожалению, кармы пока не хватает для плюса

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