Генератор зданий в Юнити.
В данной статье я постараюсь описать процесс написания параметрического генератора зданий внутри игрового движка Unity.
Статья разделена на следующие разделы:
Описание базовой логики
Примеры структуры кода
Вид требуемых моделей
Дополнительный функционал
Известные "проблемы"
Итоги/файлы проекта
1. Описание базовой логики
Генерация здания будет происходить по довольно простому принципу: введение параметров осей по 3ем координатам, определение трансформации и дальнейшее создания объектов в необходимых позициях. При этом форма здания будет ограничена параллелепипедом.
Само здание может быть поделено на следующие составляющие:
Передний и задний фасады
Торцевые или боковые фасады
Крыша
Первый этаж
Углы
Теперь перейдем к непосредственной логике построения частей здания.
В данном случае мы создаем объекты в координатах кратным шагу nTile и числом равному nNumber. То есть создается цикл nTile*nNumber, где n-выбранная ось. В цифрах или координатной сетке это будет выглядеть так (для шага кратному 1): 0,1,2,3,4.
В дальнейшем будут использоваться оси Unity: X - длина, Y - высота, Z - ширина.
Имея такой цикл его можно повторить сразу для двух осей, то есть в ширину и в высоту
( для основного фасада).
Так же точно такой же модуль можно продублировать и на задний фасад, добавив в координатах значение ширины. При этом задний фасад не будет являться копией переднего. Поэтому можно будет его изменять отдельно.
Торцевые фасады создаются аналогичным образом, используя при этом оси построения стен Y и Z и значение длины для определения позиции стены.
Значения длины, ширины и высоты здания являются умножением шага на число повторов цикла, т.е. nDimension = nTile * nNumber.
Создание кровли идет отдельно, по осям X и Z (длина и ширина). В текущем виде генератора определение последнего этажа происходит из-за иной логики построения цикла, поэтому этап отделения кровли можно считать законченным.
Далее стоит обозначить и отделить следующие модули: первый этаж, последний этаж и углы.
Первый этаж определяется как нулевое число по высоте для всех видов фасадов. Последний же как само число высоты (zNumber).
Углы для фасадов как первое и последнее число, повторенное по высоте, в циклах длины и ширины соответственно.
Углы кровли идут двойным блоком: первое и последнее число длины и ширины. То есть для кровли мы определяем все 4 угла, в то же время как для фасадов всего 2 на стену.
Имея такое определение координат и разделение стен мы можем добавлять различные элементы на необходимые составляющие здания. В число возможных объектов для создания входит: кондиционеры, сушилки для белья, вывески магазинов, трубы на кровле и т.п..
Финальный вид здания с определенными модулями будет выглядеть так:
Резюмируя: генерация здания происходит путем создания цикла и трансформации объектов по необходимым координатам с дальнейшим введением определения элементов здания: первый, последний этажи, углы, кровля. Всё остальное будет являться расширением данной системы, но её основная логика останется прежней.
2. Примеры структуры кода
В данном разделе будут примеры кода для всех основных элементов здания, включающих в себе создание цикла стен, кровли, разбиение стен и кровли на модули.
Начнем с простого цикла.
Создание объекта в Юнити идёт путем инстанцирования (Instantiate) и введения параметров трансформации.
Instantiate(object, transform.position, transform.rotation);
Для того чтобы повторить этот объект необходимое количество раз и добавить к каждому новому объекту инкрементальную трансформацию необходим цикл for:
for(i = 0, i < number, i++)
{
Instantiate(object, transform.position + new Vector3(i*nTile,0,0),
transform.rotation);
}
Следует пояснить, что создание любого объекта через скрипт требует привязки скрипта к объекту в сцене Юнити. Зачастую такими выступают пустые объекты. Именно такой объект является источником данных для позиции и вращении. Tansform.position и transform.rotation являют собой данные о местоположении и вращении такого объекта. Масштабирование в Юните же происходит иным способом, но об этом позже.
Создадим двойной цикл для целого фасада:
for(i = 0, i < xNumber, i++)
{
for(j = 0, j < yNumber,j++)
{
Instantiate(object, transform.position + new Vector3(i*xTile,j*yNumber,0),
transform.rotation);
}
}
Вот так просто создается целый фасад. Конечно же нужно ввести данные о значениях:
private readonly int xTile = 1;
private readonly int yTile = 1;
public int xNumber;
public int yNumber;
Значения шагов являются неизменными и скрытыми, числа x и y вводимыми переменными.
Далее создадим задний фасад. Для этого в предыдущий код добавим новую строчку.
for(i = 0, i < xNumber, i++)
{
for(j = 0, j < yNumber,j++)
{
//передний фасад
Instantiate(object, transform.position + new Vector3(i*xTile,j*yNumber,0),
transform.rotation);
//задний фасад
Instantiate(object, transform.position +
new Vector3(i*xTile,j*yNumber,zNumber*zTile),
transform.rotation);
}
}
Для поворота элементов заднего фасада нужно ввести новый вектор с вращением по оси Y.
Instantiate(object, transform.position +
new Vector3(i*xTile,j*yNumber,zNumber*zTile),
Quaternion.Euler(new Vector3(0, 180, 0)));
В данном случае ставится кватернион с созданием нового вектора вращения по оси Y.
Теперь создадим боковые фасады. Как говорилось ранее в разделе 1, торцевые фасады являются копией основных, с разницей в использовании других осей.
for(i = 0, i < zNumber, i++)
{
for(j = 0, j < yNumber,j++)
{
//левый фасад
Instantiate(object, transform.position + new Vector3(0,j*yNumber,i*zTile),
Quaternion.Euler(new Vector3(0, -90, 0)));
//правый фасад
Instantiate(object, transform.position +
new Vector3(xNumber*xTile,j*yNumber,i*zTile),
Quaternion.Euler(new Vector3(0, 90, 0)));
}
}
Если совместить две пары фасадов, то произойдет наложение угловых элементов, поэтому необходимо вычесть единицу из числа nNumber в цикле for и проверить если нулевой элемент равен числу n.
for(i = 0, i < nNumber -1, i++)
{
if(nNumber ==0)
{
//не создавать объект
}
{
//создавать объект
}
}
Создание кровли происходит путем умножения X и Z величин на их шаги.
for(i = 0, i < xNumber, i++)
{
for(j = 0, j < zNumber,j++)
{
Instantiate(object, transform.position + new Vector3(i*xNumber,0,j*zNumber),
transform.rotation);
}
}
Базовая коробка готова. Теперь перейдем к определениям первого и последнего этажей.
if(yNumber == 0)
{
//первый этаж
}
if(yNumber == yNumber)
{
//последний этаж
}
Первый этаж определяется как нулевое число по высоте, последний же как само число по высоте. Тем самым всё, что не входит в эти условия является элементами основного этажа.
Углы определяются таким же образом, только вместо yNumber берется число основной оси стены: X для переднего и заднего,Z для правого и левого торцов.
if(xNumber == 0)
{
//левый угол
}
if(xNumber == xNumber)
{
//правый угол
}
Определение углов для кровли требует проверку сразу двух условий, т.к. у каждого угла своя позиция в координационной сетке.
if(xNumber == 0 && zNumber == 0)
{
//левый нижний угол
}
if(xNumber == xNumber && zNumber == 0)
{
//правый нижний угол
}
if(xNumber == 0 && zNumber == zNumber)
{
//левый верхний угол
}
if(xNumber == xNumber && zNumber == zNumber)
{
//правый верхний угол
}
Базовая коробка готова. Теперь перейдем к описанию правил создания моделей для генератора.
3. Вид требуемых моделей
Как было сказано в разделе 1 шаг для цикла составляет 1 единицу. В нашем случае это будет 1 метр. Следовательно общие размеры модели будут равны 1х1м.
В тестовой сборке здания модули будут выглядеть так:
На изображении можно заметить, что углы имеют другой размер. Поскольку мы используем координационную сетку кратную единице, то и все смещения происходят по логике инкремента +1. При этом pivot объекта находится на границе стены. Хотя сама модель стены посередине сетки. При этом можно изменить модель и сделать стенки на границе сетки координат, но это даст больший размер модулей ( например большие угловые элементы).
Минимальный набор объектов будет следующим:
Основная стена ( окно )
Угол основной стены
Последний этаж основная стена
Последний этаж угол
Кровля ( плоскость )
Первый этаж основная стена
Первый этаж угол
В дальнейшем можно использовать несколько объектов для элемента, тем самым создавая рандомизацию здания. Подробнее об этом в главе 4.
4. Дополнительный функционал
В предыдущих главах был описан минимальный функционал и набор моделей для создания здания. В текущей же будут описаны различные методы расширения функционала и вариации внешнего вида генерируемого здания. Данный раздел будет разделен на блоки.
Начнем с центрирования здания. Генерируя объект с помощью примеров кода из главы 2 мы обнаружим, что здание начинает строиться из нуля координат (или позиции объекта parent). Чтобы исправить это нам надо вычислить длину, ширину, высоту здания и вычесть эти величины из изначального вектора при создании объекта.
xTileHalf = (float)(xTile * xNumber) / 2;
Instantiate(object, transform.position +
new Vector3(i*xNumber-xTileHalf,0,0), transform.rotation);
Это позволит сместить объект на половину общего размера по желаемой оси ( длина, ширина, высота). Конвертация во (float) в данном случае нужна для того, что наш шаг кратен 1 и, используя целые числа мы не сможем вычислить точную середину.
Перейдем к рандомизации создаваемых объектов. Самым простым вариантом является использование множества объектов с их рандомизацией. Рассмотрим стены как пример.
public Gameobject[] mainWalls;
Instantiate(mainWalls[Random.Range(0, mainWalls.Lenght)],
transform.position, transform.rotation);
Данный код означает, что при создании объекта в координатах мы берем случайный объект из множества равному от нуля до общей длины множества mainWalls. При этом каждый раз будет создаваться уникальный вид здания. Чтобы контролировать это можно ввести понятие seed.
public int randomSeed;
Random.InitState(randomSeed);
Таким образом мы сможем всегда получать желаемый вид случайно генерируемого здания.
Такие множества объектов можно внедрить для всех элементов здания: основные стены, первый, последний этажи, углы. При этом расширение количества случайно генерируемых объектов будет очень простым, т.к. код каждый раз проверяет длину множества.
Перейдем к дополнительным модулям внешнего вида здания. В примере рассмотрим создание кондиционеров в случайных точках здания. Поскольку объекты будут создаваться в тех же местах, что и основные стены нам всего лишь нужны выбрать где мы хотим, чтобы они создавались. Так же можно включить систему процентов, при которой будет считываться общее количество имеющихся объектов стен и создаваться нужное процентное соотношение дополнительных аксессуаров.
public Gameobject[] wallAccessories;
public double wallAccessoriesPercentage;
wallAccessoriesPercentage = wallAccessoriesPercentage / 100;
if (Random.value < wallAccessoriesPercentage)
{
Instantiate(wallAccessories[Random.Range(0, wallAccessories.Length)],
transform.position, transform.rotation);
}else
{
// не создавать дополнительные объекты
}
Мы проверяем если случайное число меньше введенного процента, разделенного на 100 (деление на сто необходимо, т.к. проверка процентов происходит от 0 до 1, а вводить удобнее от 0 до 100), то создается случайный объект из множества аксессуаров, в иных случаях будет пустое место.
Добавим внешнюю лестницу. Она использует те же координаты, что и основная стена, но размножение объектов идёт только по высоте (или оси z). Но, из-за особенностей конфигурации текущего алгоритма последний этаж создается в иных циклах for. Из-за чего приходиться создавать копию кода лестницы в сегменте последнего этажа (кровли).
Тем не менее код для лестницы будет выглядеть следующим образом (внутри код любого фасада)
for(i = 0, i < xNumber, x++)
{
for(j = 0, j < yNumber, j++)
{
Instantiate(stairsObject,transform.position +
new Vector3(i*xNumber,j*yNumber,0), transform.rotation);
}
}
При этом лестница будет точно в таких же позициях как и основные модули стен. Чтобы создать лестницу только в одном выбранном месте необходимо создать условие:
public int stairsPosition
if( i*xNumber == stairsPosition)
{
// лестница создается
}else
{
// лестница не создается
}
Где stairsPosition будет числом в сетке координат по выбранной оси. То есть лестница будет создаваться только в одном месте по осям X или Z.
Чтобы дать возможность выбирать фасад для создания лестницы необходимо создать переключатель switch и ввести энумератор по строке.
public enum facadeSideStairsSelector { Front, Back, Left, Right };
public facadeSideStairsSelector StairsSelectedFacade =
facadeSideStairsSelector.Front;
switch (StairsSelectedFacade)
{
case facadeSideStairsSelector.Front:
for(i = 0, i < xNumber, x++)
if( i*xNumber == stairsPosition)
{
{
for(j = 0, j < yNumber, j++)
{
Instantiate(stairsObject,transform.position +
new Vector3(i*xNumber,j*yNumber,0),
Quaternion.Euler(new Vector3(0, 0, 0)));
}
}
}
break;
case facadeSideStairsSelector.Back:
for(i = 0, i < xNumber, x++)
if( i*xNumber == stairsPosition)
{
{
for(j = 0, j < yNumber, j++)
{
Instantiate(stairsObject,transform.position +
new Vector3(i*xNumber,j*yNumber,zTile*zNumber),
Quaternion.Euler(new Vector3(0, 180, 0)));
}
}
}
break;
case facadeSideStairsSelector.Left:
for(i = 0, i < zNumber, x++)
if( i*zNumber == stairsPosition)
{
{
for(j = 0, j < yNumber, j++)
{
Instantiate(stairsObject,transform.position +
new Vector3(0,j*yNumber,i*zNumber),
Quaternion.Euler(new Vector3(0, -90, 0)));
}
}
}
break;
case facadeSideStairsSelector.Right:
for(i = 0, i < zNumber, x++)
if( i*zNumber == stairsPosition)
{
{
for(j = 0, j < yNumber, j++)
{
Instantiate(stairsObject,transform.position +
new Vector3(xTile*xNumber,j*yNumber,i*zNumber),
Quaternion.Euler(new Vector3(0, 90, 0)));
}
}
}
break;
}
Данный функционал позволяет выбрать фасад и позицию для создания лестницы. Чтобы иметь возможность создавать разные объекты, в зависимости от этажа необходимо внести разделение по разным объектам, либо выбору конкретного числа из множества.
Предположим, что лестница состоит из трех элементов: основная, для первого этажа, для последнего этажа. В таком случае в множестве будет всего 3 элемента [0,1,2]. Если предположить, что нулевой элемент это основная лестница, первый - первый этаж, а второй - последний этаж, то при вводе условий проверки этажа можно получить следующие:
if(i*yNumber == 0) // первый этаж
{
Instantiate(stairsObject[1],transform.position, transform.rotation);
}else if(i*yNumber == yNumber) // последний этаж
{
Instantiate(stairsObject[2],transform.position, transform.rotation);
}else //основной этаж
{
Instantiate(stairsObject[0],transform.position, transform.rotation);
}
Если же потребуется внедрение вариаций каждого типа лестницы, то необходимо будет вводить раздельные множества для каждого типа лестницы и включение случайного выбора элемента из этого множества.
В данный момент размеры, используемые при создании здания, вводятся вручную пользователем. Так же начальные координаты здания привязаны к объекту, в котором находится скрипт.
Чтобы ввести такой функционал нужно обращаться к граничащим размерам выбираемого объекта ( bounding box ).
boundingBoxCollider = pickedObject.GetComponent<Collider>();
boundingBoxSize = boundingBoxCollider.bounds.size;
xNumber = Mathf.RoundToInt(boundingBoxSize[0]);
yNumber = Mathf.RoundToInt(boundingBoxSize[1]);
zNumber = Mathf.RoundToInt(boundingBoxSize[2]);
Юнити обращается к коллайдеру выбранного объекта, затем переводит вектор 3 в цельные значения. Таким образом мы получаем размеры длины, ширины и высоты выбранного объекта.
Чтобы получить возможность обозначить начальные координаты для создания здания, необходимо копировать трансформацию выбранного объекта и передать его в генератор.
if (usePickedObjectPosition == true) //использует координаты выбранного объекта
{
pickedObjectPosition.position = pickedObjectPosition.position;
}
else // использует нулевые координаты
{
pickedObjectPosition.position = Vector3.zero;
}
Далее при инстанциализации в позиции необходимо добавить значение pickedObjectPosition.position.
Instantiate(stairsObject[0],transform.position +
pickedObjectPosition.position, transform.rotation)
Если значение вектора будет равно 0, то объект останется на исходном месте. Если же будут равны координатам выбранного объекта, то произойдет добавление этих значений.
Генератор использует готовые 3D объекты для создания здания. Но что если потребуется сгенерировать и их? В таком случае необходимо создать новый пустой объект, назначить на него новый скрипт и сгенерировать объект в нем. После же сохранить этот объект как prefab и назначить его как базовый элемент в генераторе здания.
Если мы просто создадим базовый объект по образу здания, то объект не будет получать координаты поворота из генератора, а будет использовать свои собственные ( нулевые ).
Чтобы избежать этого необходимо использовать значения transform.position, transform.rotation, так как они передают вектора позиции и направления при генерации в здании.
Таким образом генератор стены будет выглядеть так:
public GameObject wallObject;
public GameObject wallWindowframe;
public GameObject[] wallStone;
public GameObject[] wallGlass;
public GameObject wallWindowRod;
Instantiate(wallObject, transform.position, transform.rotation);
Instantiate(wallWindowframe, transform.position, transform.rotation);
Instantiate(wallStone[Random.Range(0, wallStone.Length)],
transform.position, transform.rotation);
Instantiate(wallWindowRod, transform.position, transform.rotation);
Чтобы определить брандмауэр (глухая пожарная стена), необходимо ввести всего 1 условие - проверка булеана. Но при этом необходимо будет создать новые объекты стен.
public bool brandmauer;
if(brandmauer == true)
{
// создание брандмауэра
}else
{
// создание обычных стен
}
При этом нужно будет внести дополнительное условие при создании лестницы.
public enum facadeSideStairsSelector { Front, Back, Left, Right };
public facadeSideStairsSelector StairsSelectedFacade =
facadeSideStairsSelector.Front;
switch (StairsSelectedFacade)
{
case facadeSideStairsSelector.Front:
// создание лестницы
break;
case facadeSideStairsSelector.Back:
// создание лестницы
break;
case facadeSideStairsSelector.Left:
for(i = 0, i < zNumber, x++)
if( i*zNumber == stairsPosition && brandmauer == false)
{
// создание лестницы
}
break;
case facadeSideStairsSelector.Right:
for(i = 0, i < zNumber, x++)
if( i*zNumber == stairsPosition && brandmauer == false)
{
// создание лестницы
}
break;
}
Сами объекты для пожарной стены будут выглядеть примерно так:
Как видно вводится всего 4 объекта: глухая стена, стена последнего этажа и углы последнего и первого этажей. В них кроется сложность, так как углы здания различаются зеркально, а не только по вращению.
В Юнити, при инстантировании объекта можно ввести параметры перемещения и вращения, но параметра масштаба нет ( как в Unreal Engine например). Для введения такого функционала необходимо обозначить инстантируемый объект как игровой, привязать его в parent’у и затем уже ввести вектор масштабирования. В готовом виде выглядит так:
GameObject gameObject1 = Instantiate(gameObject,
transform.position, transform.rotation);
gameObject1.transform.localScale = new Vector3(-1, 1, 1);
gameObject1.transform.parent = transform;
Таким образом мы изменили масштаб объекта по оси Х на -1, что отразило его зеркально.
Такую операцию необходимо провести для углов последнего и первого этажей.
5. Известные "проблемы"
В текущем виде генератор работает следующим образом: генерирует передний и задний фасады, затем торцевые с вычитанием единицы от числа шага, затем создает кровлю. Всё это усложняется делением на углы, первый и последний этажи для каждого типа. При этом каждый раз необходимо вводить свои циклы for в требуемом количестве. Лучший вариант будет изначальное введение трёх циклов for для каждой оси и создание "полнотелого" куба из объектов, а затем последующее деление и вычитание лишних элементов. Это позволит унифицировать циклы и выходные значения этих просчетов, что скажется на количестве кода в лучшую сторону.
6. Итоги/файлы проекта
В данной статья я постарался описать логику построения генератора здания с примерами кода. Данный генератор может использоваться как для проверки моделирования модульной системы зданий, так и для генерации зданий внутри Юнити.
Примеры работы генератора
Ссылка на исходники кода на GitHub.
Ссылка на проект на GoogleDisk.