Реализация и оптимизация генератора уровней в Unity

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.
В мае этого года мы обсуждали алгоритм, который используем для генерации внешнего мира в игре Fireside. Сегодня мы возвращаемся к этой теме. В прошлый раз нам удалось сгенерировать набор точек на текстуре с помощью фильтрации шума Перлина. Хотя это решение удобно, оно имеет две важные проблемы:

  1. Оно не особо быстрое
  2. На самом деле мы не создавали ассетов в Unity

Давайте устраним эти проблемы. Сегодня мы:

  1. Создадим в Unity фреймворк, который позволит нам использовать алгоритм генерации текстур
  2. При помоги сгенерированных текстур создадим ассеты в игровом мире
  3. Распараллелим генерацию текстур с помощью C# System.Threading.Tasks, чтобы ускорить процесс


Интеграция генерации карт в движок Unity


Мы будем писать Scriptable Objects движка Unity для создания модульного окружения в целях генерации карт. Таким образом, мы дадим гейм-дизайнерам свободу настройки входных данных алгоритма без необходимости работы с кодом. Если вы ещё не слышали о ScriptableObjects, то рекомендую для начала изучить документацию Unity.

Сначала нам потребуется набор различных контейнеров данных. Наш конвейер довольно сложен, и если поместить все необходимые параметры в один объект, он окажется слишком объёмным. Поэтому мы будем использовать по одному пакету данных на каждый уровень алгоритма.


Итак, карта составляется из одного или нескольких сегментов (slice), состоящих из одного или нескольких фрагментов (chunk), созданных из одной или нескольких текстур. Примечание: в большинстве алгоритмов этап сегментов пропускается, но я включил этот этап для дизайна конкретной игры и генерации путей; о причинах я расскажу в этой статье. Можно без проблем игнорировать сегменты и всё равно реализовать описанное здесь решение. При помощи очень удобного ExtendedScriptableObjectDrawer Тома Кэйла мы можем расширить настройки для простоты редактирования.


Здесь вы видите, какой тип данных и на каком уровне мы упаковываем. По сути, каждая генерируемая нами текстура будет распределять один ассет карты. Поэтому чтобы получить разнообразное распределение ассетов, нам нужно наложить друг на друга множество текстур. Разбиение карты на фрагменты и сегменты позволяет нам изменять генерируемые ассеты в соответствии с расстоянием от точки начала координат.

Уровень Тип данных
Map (Карта)
  • Конвейер, используемый для генерации текстур
  • Масштаб, сопоставляющий пространство текстуры с мировым пространством
  • Параметры для дорог
  • Seed
  • Какой сегмент (slice) будет использоваться на каком расстоянии от точки начала координат
Slice (Сегмент)
  • Какой фрагмент (chunk) используется в зависимости от расстояния до центра сегмента
Chunk (Фрагмент)
  • Сопоставление между текстурами и ассетом, который должен располагаться на точках, сгенерированных из текстуры
  • Текстурные параметры для каждой текстуры
  • Текстурные параметры для пути
Texture (Текстура)
  • Все параметры, описанные в первой части нашего девлога по процедурной генерации карт.

Каждый уровень данных имеет связанный с ним класс C#, использующий паттерн «фабрика», который мы применяем для выполнения логики каждого этапа. Если бы мы хотели только распределять ассеты, то этапы генерации были бы очень простыми. Однако нам также нужно создать пути, по которым будет двигаться игрок. Это немного усложняет архитектуру, потому что после генерации точек нам нужно соединить фрагменты и сегменты.



Генератор карт


Генератор сегментов


Генератор фрагментов

Если не учитывать пока генерацию путей, то единственная логика, которая нам сейчас нужна — это преобразование сгенерированных на текстуре точек в мировое пространство. Это реализуется благодаря использованию параметра масштабирования из параметров карты, что обеспечивает нам удобный контроль над плотностью размещения ассетов.



scale = 25


scale = 50


scale = 75

internal static Vector3 TexturePointToWorldPoint(
            Vector2Int texturePoint,
            float scale,
            Plane plane,
            Vector3 origin,
            ProceduralTextureGenerationSettings settings)
        {
            float tx = (float)texturePoint.x / (float)settings.width;
            float ty = (float)texturePoint.y / (float)settings.height;
            Vector3 right = GetRight(plane) * scale * tx;
            Vector3 up = GetUp(plane) * scale * ty;
            return origin + right + up;
            
        }

Поскольку мы сохранили кажду точку в мировом пространстве со связанным с ней префабом, для расположения ассетов достаточно просто вызвать Instantiate для префаба, ссылка на который указана в соответствующем слое параметров фрагмента. Единственное, что нужно учитывать — наш алгоритм не гарантирует, что ассеты не наложатся друг на друга. Пока мы применим такое решение: дадим каждому префабу коллайдер и будем уничтожать все ассеты, с которыми пересекаемся при создании экземпляра префаба. Как сказано в нашем предыдущем девлоге, нужно вызвать Physics2D.SyncTransforms() и yield return new WaitForFixedUpdate(), чтобы проверки коллизий работали правильно.

public IEnumerator PlaceAssets(Chunk chunk)
        {
            GameObject chunkObject = new GameObject("Chunk::" + chunk.settings.name);
            chunkObject.transform.SetParent(worldRoot);
            ContactFilter2D cf2d = new ContactFilter2D();
            foreach (int layerIndex in chunk.generatedLayerAnchors.Keys)
            {
                GameObject layerParent = new GameObject();
                layerParent.name = chunkObject.name + "::" + "Layer::"+chunk.generatedLayerAnchors[layerIndex].Item1.asset.name;
                layerParent.transform.SetParent(chunkObject.transform);
                foreach (Vector3 point in chunk.generatedLayerAnchors[layerIndex].Item2)
                {
                    PlaceableAsset inst = Instantiate(chunk.generatedLayerAnchors[layerIndex].Item1.asset, layerParent.transform);
                    inst.transform.position = point;
                    Collider2D[] cols = new Collider2D[16];
                    Physics2D.SyncTransforms();
                    int numOverlaps = Physics2D.OverlapCollider(inst.mapgenerationCollider, cf2d, cols);
                    for (int i = 0; i < numOverlaps; i++)
                    {
                        if (cols[i].transform.parent != null && 
                            cols[i].transform.parent.TryGetComponent<PlaceableAsset>(out PlaceableAsset toDestroy))
                            Destroy(cols[i].transform.parent.gameObject);
                    }
                }
                yield return new WaitForFixedUpdate();
            }
}

Вот и всё! Нам удалось преобразовать наш эксперимент на Processing в работающую систему на движке Unity! Но, увы…


Она медленная

Ускоряем работу


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

В официальной документации C# написано, что async / await являются базовой функциональностью C#. Хотя я хорошо знаком с другими возможностями. перечисленными на этом сайте, до начала проекта я не использовал ни async, ни Tasks. Основная причина заключается в том, что в Unity есть похожая функциональность. И это… (барабанная дробь) корутины. На самом деле, в руководствах по программированию на C# в качестве примера используется стандартный способ применения корутин (выполнение запроса к серверу). Это объясняет, почему я (и многие другие Unity-разработчики, которых я знаю) пока не использовал пока асинхронное программирование на C#. Однако это очень полезная возможность и мы используем её, чтобы распараллелить генерацию карт.

//Foo prints the same result as Bar
void Start(){
    Foo();
}
async Task Foo()
{
    Debug.Log(“Hello”);
    await Task.Delay(1000);
    Debug.Log(“There”);
}
void Start(){
    StartCoroutine(Bar());
}
IEnumerator Bar(){
    Debug.Log(“Hello”);
    yield return new WaitForSeconds(1.0f);
    Debug.Log(“There”);
}

Вот краткое введение в асинхронное программирование. Как и в случае с корутинами, при реализации асинхронного метода нам нужно возвращать особый тип (Task). Кроме того, нужно пометить метод ключевым словом async. Затем можно использовать ключевое слово await таким же образом, каким бы мы использовали оператор yield в корутине.

Однако существует также очень удобный метод Task.WhenAll, который создаёт Task, блокирующий исполнение, пока не будет завершён набор задач. Это позволяет нам реализовать следующее:

//Generates textures for all layers in parallel.
foreach (ChunkLayerSettings setting in settings.chunkLayerSettings)
{
    //generate texture for this chunk
    textureTasks.Add(textureGenerator.GenerateTextureRoutine(
        setting.textureSettings,
        seed,
        chunkCoords,
        new TextureGenerationData(seed, chunkCoords, setting.textureSettings)));
}
result = await Task<TextureGenerationData>.WhenAll(textureTasks);

В отличие от корутин, эти задачи выполняются параллельно и не тратят время выполнения в основном потоке. Теперь мы просто можем использовать такой подход при генерации как фрагментов, так и текстур. Это значительно увеличивает производительность: с примерно 10 секунд на сегмент до 3 на сегмент.

При этом мы получаем алгоритм, способный генерировать достаточно сложные и обширные карты примерно за 10 секунд (3 сегмента). Возможны дальнейшие оптимизации, а производительностью можно управлять с помощью размера используемых текстур.
Источник: https://habr.com/ru/post/520582/


Интересные статьи

Интересные статьи

Всем привет! Не так давно на работе в рамках тестирования нового бизнес-процесса мне понадобилась возможность авторизации под разными пользователями. Переход в соответствующий р...
Физика стала неотъемлемой частью любой современной игры. Будь то простая симуляция ткани или полноценная физика движения транспорта. Не являются исключением и мобильные игры. Однако, настраивая...
На размышления меня натолкнула статья об использовании «странной» инструкции popcount в современных процессорах. Речь пойдет не о подсчете числа единичек, а об обнаружении признака окончания Си с...
Вы задавались когда-нибудь вопросом, как в играх наподобие Super Meat Boy реализована функция реплея? Один из способов её реализации — выполнять ввод точно так же, как это делал игрок, что, в с...
← Часть 2. Начало работы Библиотека генератора ассемблерного кода для микроконтроллеров AVR Часть 3. Косвенная адресация и управление потоком исполнения В предыдущей части мы достаточно подроб...