Процедурная генерация пространства подразумевает под собой создание механизмов, позволяющих создавать и воссоздавать его отдельные элементы независимо друг от друга. В дальнейшем к этим элементам будет применяться термин - сектор. В прошлый раз мы воспользовались подготовленным ранее алгоритмом генерации Шума Перлина, сгенерировали с его помощью карту высот, имитирующую земную поверхность и преобразовали её в изометрическую тайловую карту. Соответственно следующими шагами станут деление карты на сектора, их раздельная генерация и последующее объединение в одну общую карту.
Цель
Сгенерировать при помощи Шума Перлина данные для сектора и объединить несколько секторов в общую карту перекрывающую камеру.
Изометрическая проекция
В предыдущей статье использовались стандартные для Unity механизмы работы с изометрическими картами, работа с координатами не выходила за обычное позиционирование тайлов друг за другом и не требовала глубокого понимания происходящего. Однако дальнейшие наши действия потребуют базового представления об изометрическом пространстве, так что немного взглянем на теорию. Как всегда, я попробую не сильно углубляться и рассмотрю только важные для текущей статьи элементы, за дополнительными сведениями вы всегда можете обратиться к интернету, как вариант начать с википедии.
Изометрическая проекция - это отображение трёхмерного объекта на плоскости с разными коэффициентами искажения по осям координат. В прошлый раз мы выбрали подход для позиционирования с помощью осей X и Y, а задание высоты происходило с помощью оси Z. Соответственно хотелось бы отметить наш переход в трёхмерное пространство и определиться с терминами для определения направления. По оси X мы будем отсчитывать длину, по оси Y - ширину и по Z - высоту. Высота в данный момент не является для нас значимым фактором, так что мы опустим её рассмотрение и сосредоточимся на осях X и Y.
Самым простым средством представления пространства будет его отображение на плоскости, для примера координаты тайлов в двухмерном пространстве:
По умолчанию координатные оси в изометрических тайловых картах Unity направлены вверх(относительно отображения на экране), соответственно получается такая изометрическая проекция нашего пространства:
Сектор
Сектор в нашем случае представляет из себя группу тайлов, с одинаковой длиной и шириной (высоту, как мы договорились ранее опускаем). Сразу отмечу, что сектора ведут себя в пространстве так же, как и отдельные тайлы, так что можно абстрагироваться от того, с какими объектами мы в данный момент имеем дело. В основном мы будем оперировать именно секторами.
Сектор в двухмерном пространстве:
Сектор в изометрическом пространстве:
Отображение изометрического пространства на экране
Думаю понятно, что изменение направления координатных осей меняет применение длины и ширины для определения количества строк и столбцов, необходимых для заполнения всего экрана. Прямое применение длины и ширины либо оставит не заполненными углы экрана, либо расположит большу́ю часть секторов за границами видимой области.
Наиболее эффективное размещение секторов выглядит таким образом:
Количество столбцов можно вычислить по формуле int sectorCountX = Mathf.CeilToInt(cameraWidth / (TileMapSector.SectorSizeInTiles * cellSize.x)) + 1; увеличение на единицу закрывает крайние значения.
Соответственно, формула для количества строк int sectorCountY = 2 * (Mathf.CeilToInt(cameraHeight / (TileMapSector.SectorSizeInTiles * cellSize.y)) + 1); где увеличение на единицу также закрывает крайние значения, а удвоенный размер берётся из-за учёта сдвига строк относительно друг друга лишь на половину высоты тайла.
Данная формула не даст нам идеального размещения и будет содержать равное количество секторов в каждой строке, но сейчас мы поступимся оптимизацией ресурсов ради сокращения сложности кода.
Обход пространства
Вишенкой на торте будет алгоритм обхода этого “эффективного” размещения секторов.
Для смещения по строкам используется последовательное увеличение координат X и Y. Для смещения по столбцам, в соответствии с изометрической координатной сеткой, необходимо увеличивать X на единицу, а Y уменьшать.
Реализация
Основным нововведением в данной статье является добавление секторов, что потребовало небольшого рефакторинга и решения следующих четырёх задач:
Расчёт смещения для генерации Шума Перлина
Расчёт количества секторов, необходимых для заполнения экрана
Расчёт положения секторов в пространстве
Обход секторов для заполнения экрана
Рефакторинг генератора шума
Настройки генератора шума были вынесены в отдельную структуру, что упростит их передачу между различными объектами.
usingUnityEngine;publicstaticclassNoiseMapGenerator{publicstaticfloat[]GenerateNoiseMap(GeneratorSettingssettings,Vector2offset){// Учёт сдвига из настроек генератора
Vector2generationOffset=offset+settings.offset;// Размеры генерируемой зоны по умолчанию равны размеру сектора
returnGenerateNoiseMap(TileMapSector.SectorSizeInTiles,TileMapSector.SectorSizeInTiles,settings.seed,settings.scale,settings.octaves,settings.persistence,settings.lacunarity,generationOffset);}publicstaticfloat[]GenerateNoiseMap(intwidth,intheight,intseed,floatscale,intoctaves,floatpersistence,floatlacunarity,Vector2offset){// Массив данных о вершинах, одномерный вид поможет избавиться от лишних циклов впоследствии
float[]noiseMap=newfloat[width*height];// Порождающий элемент
System.Randomrand=newSystem.Random(seed);// Сдвиг октав, чтобы при наложении друг на друга получить более интересную картинку
Vector2[]octavesOffset=newVector2[octaves];for(inti=0;i<octaves;i++){// Учитываем внешний сдвиг положения
floatxOffset=rand.Next(-100000,100000)+offset.x;floatyOffset=rand.Next(-100000,100000)+offset.y;octavesOffset[i]=newVector2(xOffset/width,yOffset/height);}if(scale<0){scale=0.0001f;}// Учитываем половину ширины и высоты, для более визуально приятного изменения масштаба
floathalfWidth=width/2f;floathalfHeight=height/2f;// Генерируем точки на карте высот
for(inty=0;y<height;y++){for(intx=0;x<width;x++){// Задаём значения для первой октавы
floatamplitude=1;floatfrequency=1;floatnoiseHeight=0;floatsuperpositionCompensation=0;// Обработка наложения октав
for(inti=0;i<octaves;i++){// Рассчитываем координаты для получения значения из Шума Перлина
floatxResult=(x-halfWidth)/scale*frequency+octavesOffset[i].x*frequency;floatyResult=(y-halfHeight)/scale*frequency+octavesOffset[i].y*frequency;// Получение высоты из ГСПЧ
floatgeneratedValue=Mathf.PerlinNoise(xResult,yResult);// Наложение октав
noiseHeight+=generatedValue*amplitude;// Компенсируем наложение октав, чтобы остаться в границах диапазона [0,1]
noiseHeight-=superpositionCompensation;// Расчёт амплитуды, частоты и компенсации для следующей октавы
amplitude*=persistence;frequency*=lacunarity;superpositionCompensation=amplitude/2;}// Сохраняем точку для карты высот
// Из-за наложения октав есть вероятность выхода за границы диапазона [0,1]
noiseMap[y*width+x]=Mathf.Clamp01(noiseHeight);}}returnnoiseMap;}}
Рефакторинг тестового отображения сгенерированного пространства
Тестовый рендеринг отделён от основного класса генерирующего изометрические карты и фактически повторяет алгоритм генерации изометрических структур, но для двухмерной текстуры.
usingSystem;usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicclassNoiseMapRenderer:MonoBehaviour{// Структура с зависимостью цвета пикселя от высоты карты
[Serializable]publicstructTerrainLevel{publicfloatheight;publicColorcolor;} [SerializeField]publicList<TerrainLevel>terrainLevel=newList<TerrainLevel>();// Функция для генерации тестовой текстуры с параметрами, заданными для генератора шума
// Используется из расширения редактора
publicvoidGenerateMap(){// Перевод настроек тайлов в структуру зависимости цвета пикселя от высоты
TileMapHandlerhandler=FindObjectOfType<TileMapHandler>();List<Tile>tileList=handler.tileList;terrainLevel=newList<TerrainLevel>();floatheightOffset=1.0f/tileList.Count;for(inti=0;i<tileList.Count;i++){Colorcolor=tileList[i].sprite.texture.GetPixel(tileList[i].sprite.texture.width/2,tileList[i].sprite.texture.height/2);TerrainLevellev=newTerrainLevel();lev.color=color;lev.height=Mathf.InverseLerp(0,tileList.Count,i)+heightOffset;terrainLevel.Add(lev);}// Рендеринг тестового отображения пространства для нашей камеры
Vector2IntsectorSize=handler.GetScreenSizeInSectors();RenderMap(sectorSize.x,sectorSize.y,handler.generatorSettings);}// Создаём и отрисовываем текстуру на основе шума
publicvoidRenderMap(intsectorLength,intsectorWidth,GeneratorSettingssettings){// Удаление ранее сгенерированных текстур для режима редактора
for(inti=transform.childCount;i>0;--i)DestroyImmediate(transform.GetChild(0).gameObject);// Исходные параметры для обхода секторов
intstartX=-sectorLength;intstartY=0;intcurrentSectorX=startX;intcurrentSectorY=startY;boolincreaseX=true;// Для оптимизации использования цикла при помощи остатка от деления сдвигаем индекс на единицу
intlastSectorIndex=sectorLength*sectorWidth+1;for(intsectorIndex=1;sectorIndex<lastSectorIndex;sectorIndex++){// Генерируем текущий сектор
GenerateSector(newVector2Int(currentSectorX,currentSectorY),settings);// Сдвигаемся на следующий столбец
currentSectorX++;currentSectorY--;// Переход на следующую строку
if(sectorIndex%sectorLength==0){// Переход к следующей строке с чередованием увеличения X и Y
if(increaseX){currentSectorX=++startX;currentSectorY=startY;increaseX=false;}else{currentSectorX=startX;currentSectorY=++startY;increaseX=true;}}}}privatevoidGenerateSector(Vector2IntsectorCoordinates,GeneratorSettingssettings){// Расчёт сдвига сектора для генератора шума
intsectorSize=TileMapSector.SectorSizeInTiles;floatsectorOffsetX=sectorSize*sectorCoordinates.x*sectorSize/settings.scale;floatsectorOffsetY=sectorSize*sectorCoordinates.y*sectorSize/settings.scale;Vector2sectorOffset=newVector2(sectorOffsetX,sectorOffsetY);// Генерация шума
float[]noiseMap=NoiseMapGenerator.GenerateNoiseMap(settings,sectorOffset);// Генерация и расположение текстуры сектора
GameObjectsectorSprite=newGameObject();SpriteRendererspriteRenderer=sectorSprite.AddComponent<SpriteRenderer>();Texture2Dtexture=newTexture2D(sectorSize,sectorSize){wrapMode=TextureWrapMode.Clamp,filterMode=FilterMode.Point};texture.SetPixels(GenerateColorMap(noiseMap));texture.Apply();spriteRenderer.sprite=Sprite.Create(texture,newRect(0.0f,0.0f,texture.width,texture.height),newVector2(0.5f,0.5f),100.0f);sectorSprite.transform.SetParent(transform,false);sectorSprite.name=""+sectorCoordinates.x+"_"+sectorCoordinates.y;floatpositionOffsetMultiplier=sectorSize*transform.localScale.x/spriteRenderer.sprite.pixelsPerUnit;sectorSprite.transform.Translate(newVector3(sectorCoordinates.x*positionOffsetMultiplier,sectorCoordinates.y*positionOffsetMultiplier),Space.Self);}// Конвертируем уровень шума в цвет для отрисовки текстуры
privateColor[]GenerateColorMap(float[]noiseMap){Color[]colorMap=newColor[noiseMap.Length];for(inti=0;i<noiseMap.Length;i++){// Базовым является цвет с наибольшим уровнем
colorMap[i]=terrainLevel[terrainLevel.Count-1].color;foreach(varlevelinterrainLevel){// Выбираем цвет в зависимости от уровня шума
if(noiseMap[i]<level.height){colorMap[i]=level.color;break;}}}returncolorMap;}}
usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicstaticclassTileMapSector{publicstaticintSectorSizeInTiles=10;publicstaticvoidGenerateSector(float[]noiseMap,List<Tile>tileList,TilemaptileMap){// Размеры сектора
intlength=SectorSizeInTiles;intwidth=SectorSizeInTiles;// Очистка предыдущих значений
tileMap.ClearAllTiles();// Обход всех тайлов сектора
for(inty=0;y<length;y++){for(intx=0;x<width;x++){// Уровень шума для текущего тайла
floatnoiseHeight=noiseMap[length*y+x];// Уровни генерируемой поверхности равномерно распределены по шкале шума
// "Растягиваем" шкалу шума до размеров массива тайлов
floatcolorHeight=noiseHeight*tileList.Count;// Выбираем тайл ниже получившегося значения
intcolorIndex=Mathf.FloorToInt(colorHeight);// Учитываем адресацию в массивах для максимальных значений шума
if(colorIndex==tileList.Count){colorIndex=tileList.Count-1;}// Ассеты тайлов позволяют использовать высоту в 2z
// Поэтом "растягиваем" шкалу шума больше чем с цветом в 2 раза
floattileHeight=noiseHeight*tileList.Count*2;inttileHeightIndex=Mathf.FloorToInt(tileHeight);// Сдвигаем полученную высоту, чтобы выровнять тайлы с водой и первым уровнем песка
tileHeightIndex-=4;if(tileHeightIndex<0){tileHeightIndex=0;}// Берём ассет тайла в зависимости от преобразованного уровня шума
Tiletile=tileList[colorIndex];// Устанавливаем высоту тайла в зависимости от преобразованного уровня шума
Vector3Intp=newVector3Int(x-length/2,y-width/2,tileHeightIndex);tileMap.SetTile(p,tile);}}}}
Расчёт смещения для генерации Шума Перлина
В нашей реализации генератора учёт смещения координат для получения шума выглядит таким образом:
// Сдвиг октав для получения более интересной картинки при наложении их друг на друга
Vector2[]octavesOffset=newVector2[octaves];for(inti=0;i<octaves;i++){// Учёт внешнего сдвига положения
floatxOffset=rand.Next(-100000,100000)+offset.x;floatyOffset=rand.Next(-100000,100000)+offset.y;octavesOffset[i]=newVector2(xOffset/width,yOffset/height);}// Учёт половины ширины и высоты, для более визуально приятного изменения масштаба
floathalfWidth=width/2f;floathalfHeight=height/2f;// Генерация точки на карте высот
for(inty=0;y<height;y++){for(intx=0;x<width;x++){// Обработка наложения октав
for(inti=0;i<octaves;i++){// Рассчитываем координаты для получения значения из Шума Перлина
floatxResult=(x-halfWidth)/scale*frequency+octavesOffset[i].x*frequency;floatyResult=(y-halfHeight)/scale*frequency+octavesOffset[i].y*frequency;}}}
Соответственно для правильного расчёта смещения секторов применяется такая формула:
usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicclassTileMapHandler:MonoBehaviour{// Префаб тайловой карты
publicGameObjecttileMapPrefab=null;// Сетка для отображения сгенерированных тайловых карт
publicGridgrid=null;// Список тайлов
publicList<Tile>tileList=newList<Tile>();// Настройки генератора
[SerializeField]publicGeneratorSettingsgeneratorSettings;// Список сгенерированных карт
privatereadonlyList<Transform>_tileMaps=newList<Transform>();// Start is called before the first frame update
voidStart(){// Скрытие тестовой текстуры
NoiseMapRenderermapRenderer=FindObjectOfType<NoiseMapRenderer>();mapRenderer.gameObject.SetActive(false);// Размер экрана в секторах
Vector2IntscreenSizeInSectors=GetScreenSizeInSectors();// Параметры для позиционирования секторов в пространстве
vartileSize=grid.cellSize;floatpositionOffsetMultiplierX=TileMapSector.SectorSizeInTiles*tileSize.x*0.5f;floatpositionOffsetMultiplierY=TileMapSector.SectorSizeInTiles*tileSize.y*0.5f;// Параметры для обхода тайлов по столбцам-строкам
intstartX=-screenSizeInSectors.x;intstartY=0;intcurrentSectorX=startX;intcurrentSectorY=startY;boolincreaseX=true;// Для оптимизации использования цикла при помощи остатка от деления сдвигаем индекс на единицу
intlastSectorIndex=screenSizeInSectors.x*screenSizeInSectors.y+1;for(intcurrentSectorIndex=1;currentSectorIndex<lastSectorIndex;currentSectorIndex++){// Объект сектора
Transformsector=GetTileMapSector(newVector2Int(currentSectorX,currentSectorY));sector.SetParent(grid.transform);// Рассчитываем положение сектора в пространстве
floatpositionX=currentSectorX*positionOffsetMultiplierX-currentSectorY*positionOffsetMultiplierX;floatpositionY=currentSectorX*positionOffsetMultiplierY+currentSectorY*positionOffsetMultiplierY;sector.Translate(newVector3(positionX,positionY,0),Space.Self);sector.name=""+currentSectorX+","+currentSectorY;// Сохраняем сектор
_tileMaps.Add(sector);// Переход на следующий столбец
currentSectorX++;currentSectorY--;// Переход на следующую строку
if(currentSectorIndex%screenSizeInSectors.x==0){if(increaseX){currentSectorX=++startX;currentSectorY=startY;increaseX=false;}else{currentSectorX=startX;currentSectorY=++startY;increaseX=true;}}}}// Размер экрана в секторах
publicVector2IntGetScreenSizeInSectors(){// Высота камеры
floatcameraHeight=Camera.main.orthographicSize*2;// Ширина камеры равна высота камеры * соотношение сторон
floatscreenAspect=Camera.main.aspect;floatcameraWidth=cameraHeight*screenAspect;// Расчёт количества тайлов для заполнения экрана
varcellSize=grid.cellSize;intsectorCountX=Mathf.CeilToInt(cameraWidth/(TileMapSector.SectorSizeInTiles*cellSize.x))+1;intsectorCountY=2*(Mathf.CeilToInt(cameraHeight/(TileMapSector.SectorSizeInTiles*cellSize.y))+1);returnnewVector2Int(sectorCountX,sectorCountY);}// Генерация сектора
privateTransformGetTileMapSector(Vector2Intsector){// Объект тайловой карты
GameObjectsectorGameObject=Instantiate(tileMapPrefab);// Расчёт смещения сектора для генератора шума
intsectorSize=TileMapSector.SectorSizeInTiles;floatsectorOffsetX=sectorSize*sector.x*sectorSize/generatorSettings.scale;floatsectorOffsetY=sectorSize*sector.y*sectorSize/generatorSettings.scale;Vector2sectorOffset=newVector2(sectorOffsetX,sectorOffsetY);TilemapsectorTileMap=sectorGameObject.GetComponent<Tilemap>();// Генерация сектора тайловой карты
TileMapSector.GenerateSector(NoiseMapGenerator.GenerateNoiseMap(generatorSettings,sectorOffset),tileList,sectorTileMap);returnsectorGameObject.transform;}}
Результат
Тестовая текстура:
Результат генерации изометрической карты, поделённой на сектора:
Заключение
Это небольшой шаг для нашего примера процедурной генерации, но в нём решены такие ключевые задачи, как позиционирование области генерации Шума Перлина, базовые механизмы работы с изометрической системой координат и разделение пространства на сектора. Эти три задачи являются подготовкой к динамической загрузке секторов при перемещении и фактически созданию бесконечного пространства, но этим мы займёмся в следующей статье нашего
цикла . Пока! =)