GrabDuck

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

:

Давным давно я работал с 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" соответственно.

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

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