Поводом для написания данной статьи послужила эта публикация про "хрущёвки", в которой была поднята интересная для меня тема программной генерации мешей в Unity.
Моя статья не предназначена для тех, кто уже давно работает на Unity, маловероятно, что здесь будет что-то новое для людей, знакомых с тонкостями Unity. Также для совсем новичков здесь возможны неочевидные "трудности" и зачем нужно что-то придумывать, когда итак в редакторе всё есть. Наиболее подходящей нашей аудиторией являются разработчики, которые уже кое-что умеют делать в Unity, но они ещё не решали задачи динамического изменения мешей в редакторе. Также, возможно, некоторым будет полезен наш опыт работы с инспектором.
Автор не претендует на абсолютное знание предметной области, я лишь хочу рассказать своими словами решение некоторых задач, которые появлялись при создании ассета.
Прочтя вышеуказанную статью, я сразу понял, что это то что нужно для нашего проекта. Первоначально мы собирались создавать дома из экспортированных файлов формата fbx. Конечно, делать один в один как было описано, мы не стали, мы оттолкнулись от базовой идеи и сразу поставили себе задачу: редактирование и просмотр зданий непосредственно в редакторе Unity без переключения в режим запущенной игры. Как следствие предыдущей идеи получилось уместить все настройки здания в одном скрипте и, в результате, в инспекторе объектов получилось редактировать всё строение целиком в одном месте.
Небольшое отступление
Начну с самоиронии. Наша небольшая команда некоторое время назад взялась за разработку игр для мобильных платформ. Начав с амбициозной задачи создания собственного игрового движка, мы приступили к работе. По имеющемуся собственному опыту разработки в других сферах деятельности и чтения книг про создание движков для игр, было решено всё писать на чистом си(PureC). Было реализовано несколько ключевых базовых подсистем своего движка: оптимизированный менеджер памяти; графический рендерер; система иерархии объектов на основе компонентного подхода (публикация на хабре) к созданию составных моделей; привязали LUA для возможности скриптинга создаваемых игр; реализовали возможность хранить в локальной базе данных на основе SQLite всего и вся (в том числе и скрипты на Lua) и прочее. После довольно продолжительного времени реализации всего вышеописанного и освоения интерфейсов, предоставляемых OpenGL, мы пришли к неутешительному выводу: полноценный движок мы может быть и создадим, но очень нескоро.
Переход на Unity
Естественно, параллельно с созданием собственного движка, мы интересовались достижениями игровой индустрии. Нас очень привлекал Unreal Engine, но минимальный проект 30...45МБ для мобильных платформ сразу же остудил нас, и мы решили поискать другие решения, более-менее подошла Shiva, после непродолжительного изучения данного движка, мы всё же решили поискать ещё варианты.
Наконец, недавно мы попробовали свои силы в Unity. Синтаксис C# в Unity был частично освоен, но довольно трудно было привыкать к тому, что благодаря системе сборки мусора (GC) не всегда нужно было освобождать созданные и уже не нужные ресурсы.
Выбранная тематика для работы
Перед описанием дальнейшей работы сразу скажу об ограничениях: я начал изучать сразу Unity 5, то есть все примеры должны быть работоспособны в 5-й версии Unity, за предыдущие 4-ю, а тем более 3-ю версию я никаких гарантий дать не могу. По собственному опыту могу сказать, что некоторые уроки/скрипты, которые мне попадались для 4-й версии, получалось запускать на 5-ке, некоторые требовали переформатирования на новую версию и успешно запускались, а некоторые так и не удалось запустить, приходилось менять синтаксис команд. Про обратную совместимость версий мне ничего не известно (небольшое дополнение: перед публикацией ассета, указанные ниже скрипты я успешно протестировал на Unity 4.5.0).
Для визуального наполнения игры было решено насытить окружающий фон разнообразными строениями. Хотелось сразу в редакторе видеть примерный внешний вид построек. Для того, чтобы иметь возможность "запускать" скрипт непосредственно в редакторе, необходимо перед классом, унаследованным от MonoBehaviour, написать строку [ExecuteInEditMode], т.е. создайте в проекте скрипт MyTest.cs и измените его по нижеследующему шаблону:
using UnityEngine;
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
//здесь всё как обычно,
//идет перечисление
//полей и методов
}
При изменении любого поля в инспекторе, будет вызываться метод скрипта (если он реализован) Update(). Для того, чтобы сэкономить на размере собранных для игры скриптов, можно данный метод "экранировать" парой директив #if UNITY_EDITOR… #endif, таким образом вырисовывается примерный шаблон изменяемого напрямую в инспекторе скрипта для компонента:
using UnityEngine;
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
public int i=10;
#if UNITY_EDITOR
void Update () {
Debug.Log ("Update");
}
#endif
}
Прикрепите данный скрипт к какому либо компоненту на сцене и изменяйте в инспекторе значение поля i, в консоли сразу же будет отображаться "Update" (точнее количество сообщений "Update" будет увеличиваться), в остальное время скрипт будет "ожидать" изменения публичных полей.
Необходимо отметить, что при наличии на сцене большого количества компонентов с данным скриптом, сцена будет заметно подтормаживать. Чтобы избавиться от этого, необходимо обработчик изменений в инспекторе переместить в скрипт в специальной папке Editor. Содержимое этой папки не будет попадать в готовый проект. Давайте создадим папку Editor, и в ней скрипт MyTestInspector.cs:
using UnityEngine;
using UnityEditor;
using System.Collections;
[CustomEditor(typeof(MyTest))]
public class MyTestInspector : Editor {
//пишем свой обработчик инспектора
public override void OnInspectorGUI()
{
//рисуем инспектор по умолчанию
DrawDefaultInspector ();
//получаем ссылку на выделенный объект
MyTest mt = target as MyTest;
//выполняем метод объекта,
//реагирующий на изменения в инспекторе
mt.DoRefresh();
}
}
Посмотрим на изменённый скрипт MyTest:
using UnityEngine;
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
public int i=10;
public void DoRefresh () {
Debug.Log ("Update");
}
}
Настройка инспектора для более удобной работы
После изучения официальной справки и гугления, мы сначала остановились на варианте, который предусматривал перерисовку публичных свойств в инспекторе и реакции на их изменение соответствующими методами в скрипте, но затем было решено разбивать исходные данные для редактирования на взаимосвязанные структуры данных, с которыми легко оперировать не теряясь в примерно 130 одновременно видимых настройках для построения здания. После этого необходимость в "собственном инспекторе" отпала. Однако, надеемся в будущем вернуться к этому подходу при создании и редактировании компонентов. Если кому-то интересно, то могу дать ссылки: тут и тут.
Некоторые тонкости создания интерфейса
При обычном создании публичной переменной типа int или float, например таком:
public int iterator=2;
public float dx=0.5f;
В инспекторе они отображаются как простые поля редактирования, при частом изменении этих значений в процессе работы жутко надоедает постоянно попадать курсором мышки в поля, вводить осмысленные цифры, мало отличающиеся друг от друга, и смотреть на полученные изменения, гораздо удобней другой подход. При объявлении полей сразу перед переменной (строкой выше) указывать допустимый диапазон для вводимых значений:
[Range(-10, 10)]
public int iterator=2;
[Range(0.0f, 1.0f)]
public float dx=0.5f;
После такого дополнения перед переменными, в инспекторе достаточно сдвигать ползунок для плавного или скачкообразного изменения значения.
Для того, чтобы не показывать одновременно все поля в скрипте, а у нас их около 130, можно прибегнуть к группировке сильно связанных друг с другом значений в один класс, и уже этот класс объявлять публичным полем в скрипте. Для того, чтобы можно было сохранять изменения полей в отдельных экземплярах классов и показывать поля в инспекторе, необходимо перед объявлением класса (опять же строкой выше) записать строку [System.Serializable], в итоге получаем:
[System.Serializable]
public class LeftRightSide {
[Range(0, 100)]
public int leftSide=3;
[Range(0, 100)]
public int rightSide=20;
}
После того, как вы объявите данный класс публичным полем в своем скрипте, в инспекторе появится скрываемый/раскрываемый блок полей для редактирования содержимого описанного выше класса. Возможна иерархическая вложенность различных классов друг в друга и она ограничена только здравым смыслом. Такой нехитрый приём позволяет во-первых: группировать связанные друг с другом данные, а во-вторых: упрощает навигацию в инспекторе. Опишем скрипт целиком:
using UnityEngine;
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
public LeftRightSide leftRight=new LeftRightSide();
public void DoRefresh () {
Debug.Log ("Update");
}
}
[System.Serializable]
public class LeftRightSide {
[Range(0, 100)]
public int leftSide;
[Range(0, 100)]
public int rightSide;
}
Изменяемый непосредственно в редакторе меш
Подготовка компонентов для рисования меша. Для того, чтобы иметь возможность редактировать и показывать меш, можно использовать возможности редактора, например: создать пустой объект на сцене, затем добавить ему MeshFilter и MeshRenderer через пункты меню Component->Mesh->Mesh Filter и Component->Mesh->Mesh Renderer соответственно. Первый компонент ответственен за "внутреннюю" геометрию меша, второй связан с прорисовкой меша на экране. Для добавления данных компонентов есть и другой, более надежный путь. При создании скрипта необходимо дать указание добавлять указанные выше два компонента, если они отсутствуют на том компоненте, к которому прикрепляется скрипт. Для этого нужно перед объявлением класса-потомка MonoBehaviour записать строку [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]. Примерная заготовка скрипта:
using UnityEngine;
using System.Collections;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
//...
}
Немного теории (те, кто умеет работать с мешами в Unity, могут пропустить этот раздел)
Чтобы отобразить что-либо в меше компонента, необходимо заполнить меш требуемыми данными. Самый минимум которых состоит из набора вершин и так называемых фасетов (у данного названия есть синонимы, например: triangles, faces, facets, массив индексов), то есть правил для связывания вершин друг с другом. Дополнительно будет рассмотрена возможность рисования текстур, то есть нужно будет использовать uv-координаты. Также вкратце будут показаны нормали. Сразу оговорюсь, здесь я рассматриваю только "классическую" работу шейдеров, без рассмотрения работы системы частиц, сеточного режима работы и прочего. То есть будет рассматриваться режим прорисовки треугольниками, основанными на вершинах и правилах их "связки". Вообще подробное рассмотрение данной темы, т.е. мешей, шейдеров, связки вершин, фасетов, нормалей, текстурных координат, вершинных буферов, матриц и прочего может(и должно) занять объём нескольких книг, поэтому я ограничусь лишь минимальным объёмом информации, которой должно быть достаточно для построения динамических мешей, отображаемых непосредственно в редакторе.
Умея рисовать треугольник, можно нарисовать сколь угодно сложную фигуру, комбинируя между собой треугольники. Например для квадрата, ромба, параллелепипеда, трапеции и вообще четырехугольника, достаточно двух треугольников. Для рисования более сложных фигур нужно больше треугольников, но принцип от этого не изменится. То есть минимальным и достаточным примитивом для рисования всего остального, условимся считать треугольник.
Кратко упомяну про шейдеры: шейдеры — это специальным образом написанные и скомпилированные программы, которые рисуют по определённым правилам треугольники. На самом деле всё несколько сложнее, но для краткости примем такую трактовку. Упомяну лишь, что при прорисовке очередного треугольника, шейдер ничего не знает о соседних треугольниках.
Память в компьютере линейна, и где бы ни находился большой/составной блок данных (то есть структура, класс, массив, и т.д.), его содержимое находится сразу друг за дружкой, и доступ к его элементам лучше организовывать тоже линейно. А так как меш представляет из себя сложную структуру, которая может содержать довольно большие объёмы разных, но объединенных по типу конкретных данных, то и управление этими данными тоже правильно будет организовать линейным доступом. То есть необходимо создавать и заполнять массивы соответствующими данными, и затем эти массивы "присоединять" к мешу.
Перечислю некоторые нужные нам типы данных для построения мешей:
Вершины (vertices) — состоят из массива данных типа Vector3, то есть из структуры, содержащей в себе три подряд идущих данных типа float, являющихся ни чем иным как пространственными координатами одной вершины по осям x, y и z.
Индексы вершин или фасеты (triangles) — состоят из массива типа данных int, но тут нужно учитывать, что целые числа нужно группировать по 3 на один треугольник. Рассмотрим этот момент подробнее, дело в том, что для описания одного треугольника (то есть минимально рисуемого примитива) нужно указать 3 индекса вершины. Первые три числа задают прорисовку первого треугольника, вторые три — второго и т.д. Также важно упомянуть порядок обхода вершин в треугольнике. Если вы перечисляете вершины (то есть индексы вершин) по часовой стрелке, треугольник "смотрит на вас", то есть вы его видите, иначе треугольник не прорисуется и вы его не увидите, но если вы его визуально "обойдёте" с противоположной стороны, то он станет видим для вас (то есть с нового ракурса перечисление вершин "поменяется" и будет по часовой стрелке). Как можно догадаться, общая длина всего массива будет равна количеству треугольников умноженному на три.
Нормали (normals) — состоят из массива данных типа Vector3, это массив "перпендикуляров" к вершинам, размерность массива нормалей и массива вершин одинакова. Абсолютная длина каждого вектора нормали равна единице, по сути формируется "угол поворота" вершины. Зачем вообще эти нормали нужны? Они нужны для того, чтобы правильно учитывать освещение треугольника. Зная углы между нормалью, лучом источника света и глазом наблюдателя, можно рассчитать освещённость. Нормали создаются и вычисляются не для треугольников, как можно предположить, а именно для вершин по отдельности. Если нормаль задавать только треугольнику, то шейдер не узнает, как должна изменяться нормаль от треугольника к треугольнику (потому, что шейдер ничего не знает про соседние треугольники), от этого прорисовываемая фигура будет выглядеть хоть и освещённой, но сильно "угловатой". Дело в том, что шейдер при обработке каждого треугольника, равномерно изменяет некоторые параметры между вершинами от одной к другой, в том числе и нормали к вершинам. Благодаря этому получается плавное изменение освещённости даже в пределах одного треугольника. Если нормали к вершинам треугольника расходятся друг от друга, то треугольник будет выглядеть "выпуклым", если нормали сходятся, то соответственно треугольник будет "вогнутым", если нормали параллельны, треугольник будет плоским. Соседние треугольники будут строиться по этому же принципу, у них будут совпадать соответствующие вершины с нормалями, и если треугольники будут под разными, но не сильно отличающимися углами, то переход между треугольниками будет плавным, и граница между ними будет малоразличима.
UV-координаты (uv) — состоят из массива данных типа Vector2, то есть из структуры, содержащей в себе две переменные типа float, которые являются x и y координатами "внутри" текстуры. Тут нужно рассказать подробней. Левый нижний угол текстуры соответствует uv координате (0, 0), левый верхний — (0, 1), правый верхний — (1, 1) и правый нижний — (1, 0). Если вы будете брать координаты в диапазоне [0...1] то у вас будет прорисовываться частично или полностью вся текстура, в зависимости от задаваемых значений. Можно брать и значения, выходящие за указанный диапазон, тогда текстура будет повторяться столько раз, сколько вы укажете, допустим выбрана координата uv (2, 3.5), тогда по оси x текстура повторится 2 раза, а по оси y 3.5 раза. Для того, чтобы текстура могла повторяться, нужно выставить необходимые для этого флаги. Во многих случаях флаги выставлены по умолчанию. Размерность массива uv координат такая же, как и размерность вершинного массива, то есть каждой вершине соответствует текстурная координата uv.
Подытожим вышесказанное. Для создания и прорисовки меша необходимо создать массивы вершин, индексов вершин, uv-координат и нормалей.
Посмотрите на рисунок ниже, на нём схематически показано размещение вершин прямоугольника относительно центра координат. Возле углов прямоугольника указаны индексы вершин, то есть их индекс в массиве вершин. Рекомендую при построении любых фигур создавать их с "геометрическим центром" в начале координат. Это пригодится, если вам нужно будет вращать и/или масштабировать вашу фигуру с предсказуемым результатом. После создания меша вы легко сможете сместить все его вершины в нужном вам направлении.
Начнём создание меша пока только с вершин и индексов, изменим указанный выше скрипт по примеру:
using UnityEngine;
using System.Collections;
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
[ExecuteInEditMode]
public class MyTest : MonoBehaviour {
//создаём базовые настройки для баннера
public Banner banner = new Banner();
void Start () {
}
// функция обновления меша
public void DoRefresh()
{
//зарезервируем массив вершин на 3 вершины
//для рисования одного треугольника
//размер массива индексов вершин будет 3*1=3
Vector3[] v=new Vector3[3]; //массив вершин
int[] f=new int[3]; //массив индексов вершин
//зарезервируем ссылку на промежуточный меш
Mesh tmpMesh;
//вычисляем вспомогательные значения
float w2 = banner.bWidth / 2; //половина ширины баннера
float h2 = banner.bHeight / 2; //половина высоты баннера
//создаём вершины , z координата равна нулю
v [0] = new Vector3 (-w2, -h2, 0); //0-я вершина
v [1] = new Vector3 (-w2, h2, 0); //1-я вершина
v [2] = new Vector3 (w2, h2, 0); //2-я вершина
//перечисляем индексы вершин
// если мы смотрим на рисунок,
//то перечисление вершин идёт по часовой стрелке
f [0] = 0;
f [1] = 1;
f [2] = 2;
//создаем промежуточный меш
tmpMesh = new Mesh ();
//прикрепляем к мешу массивы
tmpMesh.vertices = v; //это вершины
tmpMesh.triangles = f; //это фасеты, или массив индексов вершин
//"присоединяем" меш к компоненту
GetComponent<MeshFilter> ().mesh = tmpMesh;
}
}
[System.Serializable]
public class Banner {
[Range(0.0f, 1.0f)]
public float bWidth=0.5f;
[Range(0.0f, 1.0f)]
public float bHeight=0.5f;
}
Создайте на сцене "пустой" компонент (меню GameObject->Create Empty) и прикрепите к нему скрипт, вы должны увидеть "розовый" треугольник, если треугольник не виден, повращайте камеру вокруг компонента. Попробуйте изменять ширину и высоту баннера в инспекторе, вы должны сразу же видеть изменения в треугольнике. Давайте сделаем прямоугольник. Для этого измените содержимое метода DoRefresh () на следующее:
public void DoRefresh ()
{
//зарезервируем массив вершин на 4 вершины для рисования прямоугольника
//для первого треугольника понадобятся 0-я, 1-я и 2-я вершины
//для второго понадобятся 0-я, 2-я и 3-я вершины
//так как требуется 2 треугольника,
//размер массива индексов вершин будет 3*2=6
Vector3[] v=new Vector3[4];
int[] f=new int[6];
Mesh tmpMesh;
float w2 = banner.bWidth / 2;
float h2 = banner.bHeight / 2;
v [0] = new Vector3 (-w2, -h2, 0);
v [1] = new Vector3 (-w2, h2, 0);
v [2] = new Vector3 (w2, h2, 0);
v [3] = new Vector3 (w2, -h2, 0); //3-я вершина
//перечисляем индексы вершин
//если мы смотрим на рисунок,
//то перечисление вершин идёт по часовой стрелке
//1-й треугольник
f [0] = 0;
f [1] = 1;
f [2] = 2;
//2-й треугольник
f [3] = 0;
f [4] = 2;
f [5] = 3;
tmpMesh = new Mesh ();
tmpMesh.vertices = v;
tmpMesh.triangles = f;
GetComponent<MeshFilter> ().mesh = tmpMesh;
}
После редактирования скрипта и переключения в среду Unity, наш треугольник "достроится" до прямоугольника. Теперь давайте изменим цвет прямоугольника. Для этого необходимо изменить скрипт в 2-х местах, в самом верху, где создан публичный класс Banner нужно дописать строку public Material bannerMaterial; то есть:
public Banner banner = new Banner();
//создаем ссылку на материал
public Material bannerMaterial;
А также в самом конце метода DoRefresh() допишите строку GetComponent<MeshRenderer> ().material = bannerMaterial; то есть:
GetComponent<MeshFilter> ().mesh = tmpMesh;
//прикрепляем материал, которым будет рисоваться меш
GetComponent<MeshRenderer> ().material = bannerMaterial;
После этого в инспекторе появится переменная типа Material, которой можно назначать материал, и если вы измените её значение, то прямоугольник сразу отреагирует на изменение материала, "перекрасится", но он всё равно будет залит одним цветом(в Unity 4.5.0 возможно смещение текстурных координат). Это происходит из-за того, что мешу не были даны uv координаты, давайте исправим это. Придется снова заменить метод DoRefresh () на следующий текст:
public void DoRefresh()
{
Vector3[] v=new Vector3[4];
int[] f=new int[6];
//резервируем массив uv координат на 4 вершины
Vector2[] uv=new Vector2[4];
Mesh tmpMesh;
float w2 = banner.bWidth / 2;
float h2 = banner.bHeight / 2;
v [0] = new Vector3 (-w2, -h2, 0);
v [1] = new Vector3 (-w2, h2, 0);
v [2] = new Vector3 (w2, h2, 0);
v [3] = new Vector3 (w2, -h2, 0);
f [0] = 0;
f [1] = 1;
f [2] = 2;
f [3] = 0;
f [4] = 2;
f [5] = 3;
//наполняем массив массив uv координат к каждой вершине
uv [0] = new Vector2 (0, 0); //0-я вершина, левый нижний угол текстуры
uv [1] = new Vector2 (0, 1); //1-я вершина, левый верхний угол текстуры
uv [2] = new Vector2 (1, 1); //2-я вершина, правый верхний угол текстуры
uv [3] = new Vector2 (1, 0); //3-я вершина, правый нижний угол текстуры
tmpMesh = new Mesh ();
tmpMesh.vertices = v;
tmpMesh.triangles = f;
tmpMesh.uv = uv; //это массив текстурных координат
GetComponent<MeshFilter> ().mesh = tmpMesh;
GetComponent<MeshRenderer> ().material = bannerMaterial;
}
Теперь, если у вас к материалу прикреплена картинка, она растянется по всему прямоугольнику. Но всё равно не хватает реалистичности. Для добавления реалистичности, нужно учитывать освещённость, а для этого необходимо создать нормали и добавить их в меш. В нашем конкретном случае это просто. Меш рисуется в плоскости XOY, то есть перпендикулярен оси Z. Осталось определиться со знаком z-координаты нормали. Нормали должны исходить из вершин в то полупространство(имеется в виду полупространство лицевой стороны треугольника), из которого они видны. Отредактируем метод DoRefresh() ещё раз:
public void DoRefresh()
{
Vector3[] v=new Vector3[4];
int[] f=new int[6];
Vector2[] uv=new Vector2[4];
//резервируем массив нормалей
Vector3[] n = new Vector3[4];
Mesh tmpMesh;
float w2 = banner.bWidth / 2;
float h2 = banner.bHeight / 2;
v [0] = new Vector3 (-w2, -h2, 0);
v [1] = new Vector3 (-w2, h2, 0);
v [2] = new Vector3 (w2, h2, 0);
v [3] = new Vector3 (w2, -h2, 0);
f [0] = 0;
f [1] = 1;
f [2] = 2;
f [3] = 0;
f [4] = 2;
f [5] = 3;
uv [0] = new Vector2 (0, 0);
uv [1] = new Vector2 (0, 1);
uv [2] = new Vector2 (1, 1);
uv [3] = new Vector2 (1, 0);
//создаём нормали к каждой вершине, они одинаковы,
//и направлены в сторону, противоположную оси Z
for (int i=0; i<4; i++) {
n[i]=new Vector3(0, 0, -1);
}
tmpMesh = new Mesh ();
tmpMesh.vertices = v;
tmpMesh.triangles = f;
tmpMesh.uv = uv;
tmpMesh.normals = n; //массив нормалей
GetComponent<MeshFilter> ().mesh = tmpMesh;
GetComponent<MeshRenderer> ().material = bannerMaterial;
}
Теперь, если изменять интенсивность источника света, направление освещения, можно сразу увидеть результаты на прямоугольнике.
За сим откланяюсь, статья итак получилась довольно большой. Просьба присылать в личку все замеченные ошибки и неточности.
PS: Художник из меня слабый, поэтому схематический чертёж получился не совсем явным. Также я не могу опубликовать полный исходный код постройки зданий, так как проект коммерческий.
Автор: tzar_skif