Как сделать бесконечный уровень в unity
Перейти к содержимому

Как сделать бесконечный уровень в unity

  • автор:

Unity: бесконечный процедурно генерируемый город, получаемый при помощи алгоритма WFC (коллапс волновой функции)

Как законодатели мод по теме Unity на российском рынке предлагаем вам почитать интересное исследование о практическом использовании алгоритма WFC (Wave Function Collapse), построенного по образу и подобию известного принципа квантовой механики и очень удобного при процедурной генерации уровней в играх. Ранее на Хабре уже публиковался подробный рассказ об этом алгоритме. Автор сегодняшней статьи Мариан Кляйнеберг рассматривает алгоритм в контексте трехмерной графики и генерации бесконечного города. Приятного чтения!

Мы поговорим об игре, где вы идете по бесконечному городу, который процедурно генерируется по мере вашего движения. Город строится из набора блоков при помощи алгоритма WFC (коллапс волновой функции).

Играбельная сборка доступна для скачивания на сайте itch.io. Также можете взять исходный код на github. Наконец, я предлагаю видео, в котором иду по сгенерированому таким образом городу.

Алгоритм

Я буду называть словом “ячейка” такой элемент 3D-воксельной сетки, который может содержать блок или пустовать. Словом «модуль» я буду называть блок, который может занимать такую ячейку.

Алгоритм решает, какие модули подбирать в каждую ячейку игрового мира. Массив ячеек считается волновой функцией в ненаблюдаемом виде. Таким образом, каждой ячейке соответствует множество модулей, которые могут в ней оказаться. В терминах квантовой механики можно было бы сказать, «ячейка находится в суперпозиции всех модулей». Существование мира начинается в полностью ненаблюдаемом виде, где в каждой ячейке может находиться любой модуль. Далее все ячейки схлопываются, одна за другой. Это означает, что для каждой ячейки случайным образом выбирается по одному модулю из всех возможных.

Далее следует этап распространения ограничений (constraint propagation). Для каждого модуля подбирается такое подмножество модулей, которым разрешено быть смежными с ним. Всякий раз при схлопывании модуля обновляются подмножества других модулей, которые по-прежнему допускаются в качестве смежных ему. Этап распространения ограничений – самая ресурсозатратная часть алгоритма с точки зрения вычислительной мощности.

Важный аспект алгоритма заключается в определении того, какую ячейку схлопнуть. Алгоритм всегда схлопывает ячейку с наименьшей энтропией. Это ячейка, допускающая минимальное количество вариантов выбора (то есть, ячейка с наименьшей хаотичностью). Если у всех модулей вероятность схлопывания одинакова, то наименьшая энтропия будет у той ячейки, которой соответствует минимальное количество возможных модулей. Как правило, вероятности попасть под выбор для разных наличествующих модулей отличаются. Ячейка с двумя возможными модулями, имеющими одинаковую вероятность, предусматривает более широкий выбор (большую энтропию), чем та, в которой два модуля, и для одного из них вероятность попасть под выбор очень велика, а для другого – очень мала.

(Гифка помещена ExUtumno на Github)

Более подробную информацию об алгоритме коллапса волновой функции, а также ряд красивых примеров можно посмотреть здесь. Изначально этот алгоритм был предложен для генерации 2D-текстур на основе единственного образца. В таком случае вероятностные показатели модулей и правила смежности определяются в зависимости от их встречаемости в примере. В данной статье эта информация предоставляется вручную.

Вот видео, демонстрирующее этот алгоритм в действии.

О блоках, прототипах и модулях

Мир генерируется из набора, в котором около 100 блоков. Я создал их при помощи Blender. Сначала блоков у меня было совсем немного, и я понемногу добавлял их, когда считал это нужным.

Алгоритму необходимо знать, какие модули могут располагаться рядом друг с другом. Для каждого модуля существует 6 списков возможных соседей, по одному в каждом из направлений. Однако, я хотел избежать необходимости создавать такой список вручную. Кроме того, я хотел автоматически генерировать повернутые варианты для каждого из моих блоков.

Обе эти задачи решаемы при помощи так называемых прототипов модулей. В сущности, это MonoBehaviour , с которым удобно работать в редакторе Unity. Модули вместе со списками допустимых соседних элементов и повернутыми вариантами автоматически создаются на основе таких прототипов.

Сложная проблема возникла с моделированием информации о смежности, так, чтобы этот автоматический процесс работал. Вот что у меня получилось:

У каждого блока по 6 контактов, по одному на каждую грань. У контакта есть номер. Кроме того, горизонтальные контакты могут быть перевернуты, неперевернуты или симметричны. Вертикальные контакты либо имеют индекс вращения в диапазоне от 0 до 3, либо помечаются как вращательно инвариантные.

Исходя из этого, я могу автоматически проверять, каким модулям разрешено прилегать друг к другу. У смежных модулей должны быть одинаковые номера контактов. Также должна совпадать их симметрия (одинаковый индекс вращения по вертикали, пара из перевернутого и непервернутого контакта по горизонтали), либо модули должны быть симметричны/инвариантны.

Существуют правила исключения, при помощи которых я могу запрещать варианты соседства, которые по умолчанию были бы разрешены. Некоторые блоки с подходящими друг к другу контактами попросту некрасиво выглядят рядом. Вот пример карты, сгенерированной без применения правил исключения:

Путь к бесконечности

Исходные алгоритм коллапса волновой функции генерирует карты конечного размера. Я же хотел построить мир, который будет все расширяться и расширяться по мере того, как вы по нему двигаетесь.

Сначала я попробовал генерировать фрагменты конечного размера и пользоваться контактами смежных фрагментов как ограничениями. Если фрагмент сгенерирован, а смежный ему фрагмент также сгенерирован, то допускаются лишь такие модули, которые укладываются рядом с имеющимися модулями. С данным подходом возникает такая проблема: всякий раз при схлопывании ячейки распространение ограничений будет урезать возможности даже на расстоянии в несколько ячеек. На следующем изображении заметны последствия схлопывания единственной ячейки:

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

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

В некоторых случаях данный подход не работает. Рассмотрим набор модулей для прямолинейного участка туннеля с показанного выше рисунка – входа в туннель там нет. Если алгоритм выберет такой туннельный модуль, то туннель по определению получится бесконечным. На этапе распространения ограничений программа попытается выделить бесконечное количество ячеек. Я разработал специальный набор модулей, чтобы обойти эту проблему.

Граничные условия

Здесь существуют два важных граничных условия. Все грани на верхнем уровне карты должны иметь «воздушные» контакты. Все грани на основании карты должны иметь «твердые» контакты. Если эти условия не выполняются, то на карте будут лунки в земле, а некоторые здания окажутся без крыши.

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

На карте бесконечного размера подобное не сработает, поскольку как на самом верхнем, так и на самом нижнем уровне у нас бесконечное количество ячеек. Наиболее наивное решение – удалять все неподходящие ячейки сразу по мере их возникновения. Однако, при удалении модуля на верхнем уровне срабатывают ограничения, касающиеся тех ячеек, что к нему прилегали. Возникает лавинообразный эффект, вновь приводящий к бесконечному выделению ячеек.

Я решил эту проблему, создав карту размером 1×n×1, где n — высота. Данная карта использует закольцовывание мира (world wrapping) для распространения ограничений. Механизм работает как в игре Pacman: выходя за правый край карты, персонаж возвращается на нее из-за левого края. Теперь я могу применять на моей карте распространение любых ограничений. Всякий раз при создании новой ячейки на бесконечной карте, эта ячейка инициализируется с набором модулей, соответствующим конкретной позиции на карте.

Состояния ошибок и поиск с возвратом

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

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

На мой взгляд, из-за такого ограничения применение алгоритма WFC с бесконечными мирами не подходит для коммерческих игр.

Предыстория

Я взялся за проработку этой задачи после того, как посмотрел лекцию Оскара Стельберга, рассказывающего, как он использует алгоритм для генерации уровней в игре Bad North. В общих чертах мой алгоритм был реализован во время недели procjam.

У меня есть некоторые идеи о дальнейшей доработке этого алгоритма, но я не уверен, что когда-нибудь соберусь добавить к нему геймплей. А если и соберусь – наверняка это будет не такая эпичная стратегия, которую вы себе уже вообразили. Однако, если вы хотите проверить, как работает с этим алгоритмом ваша любимая игровая механика – просто попробуйте сами! В конце концов, исходный код выложен в открытом доступе и лицензирован MIT.

  • Блог компании Издательский дом «Питер»
  • Разработка игр
  • Алгоритмы
  • C#
  • Unity

Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

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

Описаный мною подход к генерации помимо очевидных плюсов имеет и ряд жестких минусов. Так что идеальным его, конечно, не назовешь. Но, идеальных методов и не существует. Этот подход я использовал в своей недоделанной игре, которая была создана на конкурс «Храм Хаоса».

Пролог

Сначала был массив…

Нет, сначала конечно же был вопрос самому себе — зачем мне это? С этим вопросом я обратился к интернету. И бездушная машина выдала мне ответ: процедурная генерация уровней — вопрос достаточно старый и обсосанный, чтобы давать конкретные ответы.

Нет, миллион статей все же я нашел. И, по правде сказать, они были довольно интересные. Множество техник, философские рассуждения на тему «процедурщина, какая бы ни была, не заменит дизайнера», споры и срачи в комментариях. Все это было живо и весело, пока дело не дошло до математики. Времени на курсы матана и линейки у меня не было, поэтому я пошел по пути простого поиска готовых решений на любом псевдоязыке, дабы потом перевести это все на С#. И тут начинается настоящее приключение.

Классика жанра.

Так уж вышло, что почти все процедурные генераторы уровней сводятся к двум параллелям: процедурные лабиринты и процедурные данжены. Думаю, определение обоих понятий понятны и без особых иллюстраций. Лабиринты — фактически это куча коридоров, разделенных стенами. Дажены — это массивы блоков, либо заполненных (стена), либо пустых (пол). В принципе, все алгоритмы так или иначе эксплуатируют одну из этих двух параллелей (либо совмещают их). Классические подходы к генерации — это как раз совмещение обоих вариантов, когда в несколько проходов генерируется лабиринт, затем частично в стиле данжен-генераторов лабиринт заполняется либо большими помещениями (комнатами), либо непроходимыми темными зонами стен.

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

И есть одно общее, можно сказать, один мегаэлемент, объединяющий абсолютно все алгоритмы и методы генерации. Это МАССИВ. Чаще двумерный, в отдельных упоротых случаях — многомерный. Массив, задача которого хранить нагенерённые позиции зон. В классическом данжен-генераторе значения элементов в массиве это 0 и 1, стена или пол, а индексы — координаты зоны в пространстве. В лабиринтах зачастую чуток сложнее, но за рамки квадратного массива это не выходит.

У этих подходов, при всех их несомненных плюсах, есть одно НО. Массив всегда квадратный (если его искусственно не обрезают), а элемент массива представляет собой одну зону одинакового размера с остальными. То есть как бы мы сложно не задействовали алгоритмы генерации — базовый строительный элемент у нас всегда одного размера, да и чаще всего квадратный (ну или многоугольник с четным количеством граней).

Это обстоятельство поначалу никак не заботило меня. Моя задача была — запустить хоть какую-то генерацию. Я планировал сделать ряд вложенных генераторов, дабы усложнить уровень, но…

Первая попытка. Печальная.

Но, забегая вперед, скажу, что это привело лишь к убогой фрактализации всего. И это было отвратительно.

Я взял за основу один из многочисленных алгоритмов данжен-генератора. Смысла его описывать здесь не вижу, их много, и я взял тот, который уже был написан на С#, и мне оставалось лишь незначительно переписать его под Юнити-варимый вариант.

Я планировал так. Первый проход алгоритма — глобальный генератор — создает макро-уровень, где каждый элемент массива — зона 100х100 юнитов (префаб). 1 — зона, заполненная «зданием», 0 — пустая зона улицы. Далее, каждая зона «здания» внутри себя запускает свой генератор, который расставляет зоны поменьше (допустим 20х20), но с другими префабами, которые представляли собой либо комнаты (1), либо проходы без стен (0). Помимо всего прочего, каждый префаб комнат и проходов тоже мог содержать в себе генератор пропсов, но уже без особых алгоритмов.

Как мы помним из моей первой демки — все было плохо. Генерилось все хорошо, но фрактализация была жуткая, occlusion culling всего этого работал плохо, отчего тормозило дико. В конце концов, я добавил поворот на рандомный угол каждой мегазоны, что поначалу показалось мне интересным, но превратило игру еще в большую кашу…

Я отчаялся. Отчаялся и начал думать…

Пытки разума

Думал я мучительно. Смотрел кучу видосиков, читал статейки. Но везде у меня приходило в голову одно и то же — «это все не то!». Я вспомнил главного рандом-монстра игровой истории — Дьяблу. Вспомнил как круто все там было, как здорово, мегарандомно, но и мега-слажено одновременно. И вдруг я стал понимать. В Дьябле все было круто потому что игра была — изометрия про данжены!

Но у меня — экшен! Да еще и из головы! И в памяти начал всплывать угарный угар, в который я долго рубался давным давно — многими противоречиво любимо-ненавидимый Хеллгейт. Точной информации о методах генерации уровней в Хеллгейте всемирный разум мне не дал (возможно, я плохо искал), но вспоминая уровни, я начал, похоже, догадываться, что мне нужно делать для экшена. Так начали проявляться первые наброски идеи под названием CBLG (Content-Based Level Generator).

Чё за кантент, ё?

Казалось бы, при чем тут контент? Ответ на этот вопрос будет немного ниже.

А начну я с другого. Что мы знаем об экшенах? Особенно о современных экшенах, старых консольных экшенах? В основном то, что мы бежим по коридорам и комнатам, стреляем врагов, ищем ключи… Мы знаем, что в экшенах нет сетчатых уровней, лабиринтов в классике, где коридоры имеют шаг, углы 90 градусов, а комнаты статичны и пропорциональны. В экшенах окружающий нас контент — многообразен. Однообразие Вольфенштейна закончилось вместе с ним. А значит, генерация блоками здесь уже неуместна. Нужно, чтобы коридоры были кривыми, разной длины, комнаты были любой геометрии. Были спуски, подъемы, ямы, непонятные формы пола и потолка.

Становится ясно, что реализация всего этого массивами становится затруднительной, где-то даже сомнительной затеей. Что же тогда? Как гененировать? Ведь тогда генерация каждого последующего элемента геометрии уровня должна зависеть от предыдущего? То есть генерация контента должна быть в прямой и абсолютной зависимости от самого контента!

Вот оно! Решение!

Основа

Любой коридор имеет вход и выход. Любая комната имеет вход и выход, даже несколько. Всё, во что мы можем войти, имеет место, откуда мы входим — начало. И если это не тупик — имеет выход — конец. Если мы выходим «откуда-то», то мы входим в новое «куда-то». То есть конец одного места совмещен с началом другого места.

Неважно, коридор это, комната, улица — везде в шутерах есть входы и выходы. главное — их совместить. Только их. Конкретные точки в пространстве! Зная, где расположены эти точки, нам уже, по большому счету, неважно ни размер зоны, ни форма, ничего. Важно только где у зоны выход и где у следующей зоны вход.

Для упрощения задачи, примем за правило, что у зоны может быть один (и только один!) «вход» и сколь угодно выходов. Останется лишь правильно сорентировать следующую зону в пространстве относительно выхода предыдущей. И в этом деле Unity — очень большой молодец 🙂

Симуляция на пальцах

Префабы в Юнити — пожалуй, самая полезная из доступных штук. Они лучше всего подходят к нашей задаче. Максимально. Если бы их не было — их пришлось бы придумать 🙂

Итак, пусть у нас есть 2 префаба геометрии уровня: коридор и комната. У каждого префаба есть «вход» — для удобства работы вход — это точка внутри префаба, относительно которой строится вся геометрия. Обычно это уровень пола, центр дверного проема, а дальше вся геометрия строится вдоль оси Z в положительную сторону, при этом в высоту и ширину (Y и X) в любые стороны. Но Z — строго от нуля и вперед!

Это первый важный элемент нашей системы. Вход в зону — всегда !

level_gen_01 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Далее, мы создаем объект (префаб) под названием zoneExit, ставим в него пару примитивов, симулируя визуально стрелочку вдоль оси Z. Задача — конец стрелочки внутри префаба zoneExit должен находиться в точке , сама стрелочка тянется вдоль оси Z от минуса к нулю. По X и по Y — строго по нулям! Это нужно нам для визуальной ориентации выхода из зоны и последующего использования этой ориентации в генераторе.

level_gen_02 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Также, для собственного удобства и контроля, сделаем префаб zoneEntry. Он не участвует в генераторе и его можно потом удалить вообще. Но он помогает контролировать точку входа при создании нового префаба зоны, чтобы не потерять из виду в пылу дизайнерской страсти. Этот префаб — точная противоположность zoneExit-а. Он начинается строго в , и тянется вдоль Z вперед, представляя собой какую-нибудь узнаваемую фигуру:

level_gen_03 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Его мы размещаем в наших префабах зон строго в 0.0.0 и направлении forward (Z), строим дальше всю зону исключительно в положительной плоскости по Z (по X и Y при этом ограничений нет)

level_gen_04 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Размещаем zoneExit-ы в наших префабах зон так, чтобы начало предполагаемой последующей зоны встало именно так, как нам нужно. Мы знаем одно — кончик «стрелочки» zoneExit, её originPoint, по-замыслу совпадет с новой зоны, а направление нашей стрелочки — укажет ориентацию следующей зоны относительно выхода этой. Я для простоты вращал exitPoint-ы только по Y, это и логично — смысла следующий коридор наклонять относительно горизонта нет. Мы же бегать хотим 🙂

level_gen_05 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Если мы правильно все поставили, то в результате генерации зоны скрепятся довольно точно:

level_gen_06 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

level_gen_07 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

И в зависимости от того, на какой угол по Y была повернута zoneExit — наш генератор возьмет этот угол за основу и повернет следующую зону на него же:

level_gen_08 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Таким образом, в зависимости от положения zoneExit-ов в каждом префабе зоны — у нас будет строится несетчатая и довольно разнообразная геометрия уровня. Все будет зависеть ТОЛЬКО ОТ количества разнообразных префабов зон!

level_gen_09 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

level_gen_10 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Дальше — Больше!

Вся суть генератора — в отсутствии единого генератора как такового. Главный участник генерации у нас — префаб генератора зоны. Он не содержит геометрии, только скрипт генератора одной (!) зоны. После появления, он инстансит из массива префабов случайный префаб зоны себе в дочерние объекты, обходит все его zoneExit-ы, с некоторой вероятностью спавнит в них свои копии, указывая им position и rotation соответствующих exitZone-ов, и замолкает. Первый префаб мы кладем просто на уровень в глобальную точку . У меня к ней придвинута статичная зона джунглей, где стартует игрок.

level_gen_11 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Ограничение на размер уровня хранится в глобальной переменной maxZonesCount, а также каждый префаб инкрементирует глобальный счетчик zonesCount в случае, если он не превысит максимальный предел. Если следующая зона должна быть последней ( текущее значение zonesCount == maxZonesCount-1), то префаб вместо следующей зоны генерит Финальную Зону (зону босса например, или как в моей игре — зону с дверью, от которой нужно найти ключи), инкрементирует zonesCount последний раз, и дальше остальные префабы уже не могут ничего строить. В силу невозможности определения последовательности отработки скриптов в инстансах — все происходит достаточно случайно и зона босса может появиться где угодно.

Генерация почти закончена. Однако, в силу описанного выше механизма «вероятности» спавна на месте zoneExit-а нового префаба (типа RandomRange(0,5)>6), не все zoneExit-ы будут обработаны их генераторами для генерации в них следующих зон. Поэтому на сцене останется много свободных неудаленных zoneExit-ов — по сути, дырок в пустоту. Вот как раз префаб финальной зоны (босса) их всех соберет и заспавнит на них тупиковые зоны (это могут быть тупики, комнаты с лутом, секреты — все что угодно, что не имеет выхода. Даже комнаты с порталами). Согласно удобным инструментам Unity, все zoneExit-ы имеют тэг «zoneExit», по которому финальная зона их и находит, и, согласно такому же алгоритму позиционирования, как и основном генераторе, спавнит на их месте зоны тупиков.

Всё, генерация геометрии завершена. Но на этом процесс не заканчивается…

Наполняем геометрию

Каждый префаб зоны у нас определяет только геометрию — стены, пол, иногда потолок. Но нам же интересно, чтобы было еще и визуальное разнообразие! Поэтому для каждого префаба зоны мы делаем кучу префабов пропсов. Я их все строю в редакторе прямо внутри префаба, расставляю так, чтобы потом спавнить префаб пропсов прямо внутри префаба геометрии — и все стояло на своих местах. В игре у меня 5—6 префабов окружения для каждого типа зоны. Простейший скрипт propsSpawner в каждом префабе геометрии содержит паблик-массив префабов наборов пропсов, задача его — заспавнить случайный из них себе в дочерний объект. То же самое с врагами.

level_gen_12 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

И опять — чем больше разнообразных префабов — тем разнообразнее генерация!

level_gen_13 | Контент-ориентированная генерация уровня в Unity в конкурсе «Храм Хаоса»

Не все так радужно

Ну и о минусах. Без них ничего не бывает.

Во-первых, главный минус — нужно много контента. Чем больше — тем лучше, разнообразие будет зависеть от этого сильно. На то оно и Content-Based.

Во-вторых, это все еще существующая проблема оверлаппинга зон. В принципе, она решаема. Я планировал добавить каждому префабу зоны объект-контроллер с колижин-боксом, покрывающим весь префаб и в случае, если при генерации два таких объекта пересеклись — перезапускать всю генерацию заново. Это увеличит время «загрузки» уровня, но все же позволит полностью избавиться от проблемы. У себя же я частично решил это введя длиииииинные спуски вниз, чтобы развести зоны по вертикали друг от друга.

В-третьих, это уже моя личная проблема — разобраться с навмешами внутри этих префабов, а так же с offmesh-links для того, чтобы враги умели гнаться за тобой между зонами. Но это уже личные мелочи. У меня враги дальше зоны префаба не выбегают…

Если в описанном алгоритме найдете еще что-либо из минусов — буду рад их обсудить 🙂

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

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

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

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

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

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

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

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

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

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

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

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(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()

Вот краткое введение в асинхронное программирование. Как и в случае с корутинами, при реализации асинхронного метода нам нужно возвращать особый тип (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.WhenAll(textureTasks);

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

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

  • процедурная генерация
  • процедурная генерация карт
  • шум перлина
  • корутины
  • unity3d

Бесконечный раннер [платформер]

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

Для начала, нужно определиться, какого размера делать секции для карты. Чтобы решить этот вопрос, добавляем обычный куб на сцену и растягиваем его по оси Х, до тех пор, чтобы его края выходили немного за границы экрана.

Точки располагаем следующим образом. Всё зависит от значения которые получилось при растягивании куба, то есть если масштаб по оси Х равен 10, значит позиция левой точки по Х равна -10, для центральной = 0, и соответственно 10 для правой.

Шаблоны секций по размерам создаем, ориентируясь на наш куб.

Теперь, цепляем куда-нибудь скрипт:

using UnityEngine; using System.Collections; using System.Collections.Generic; using UnityEngine.SceneManagement; public class Runner2D : MonoBehaviour < public Transform[] points; public float speed = 5; private string startSectionName, sectionPath; private GameObject[] sectionLink; private Transform[] section; private GameObject sectionStart; private ListsectionDisabled; private float minPosX, addPosX; private int index; void Awake() < switch(SceneManager.GetActiveScene().name) // фильтр по имени сцен, чтобы в каждой из них, использовать свой набор шаблонов < case "Demo": startSectionName = "Start/Level_01_Start"; // стартовый префаб платформы sectionPath = "Level_01"; // папка, где лежат шаблоны для данной сцены break; >> void Start() < minPosX = points[0].position.x; addPosX = Mathf.Abs(minPosX) * 3; StartGame(); >Transform RandomSection() < sectionDisabled = new List(); foreach(Transform tr in section) < if(!tr.gameObject.activeSelf) < sectionDisabled.Add(tr); >> int rnd = Random.Range(0, sectionDisabled.Count); return sectionDisabled[rnd]; > void AddSection() < Transform bock = RandomSection(); if(index == points.Length) index = 0; bock.parent = points[index]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); index++; >void StartGame() < sectionLink = Resources.LoadAll(sectionPath); // все префабы должны находится в папке Resources if(sectionLink.Length < 4) < Debug.Log(this + " Недостаточно объектов для построения уровня. Ошибка запуска игры."); return; >section = new Transform[sectionLink.Length]; for(int i = 0; i < sectionLink.Length; i++) < GameObject clone = Instantiate(sectionLink[i]) as GameObject; clone.SetActive(false); section[i] = clone.transform; >GameObject link = Resources.Load(startSectionName); if(link == null) < Debug.Log(this + " Файл не найден: " + startSectionName + " Ошибка запуска игры."); return; >sectionStart = Instantiate(link) as GameObject; sectionStart.SetActive(true); sectionStart.transform.parent = points[1]; sectionStart.transform.localPosition = Vector3.zero; Transform bock = RandomSection(); bock.parent = points[0]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); bock = RandomSection(); bock.parent = points[2]; bock.localPosition = Vector3.zero; bock.gameObject.SetActive(true); > void Update() < foreach(Transform tr in points) < tr.position -= new Vector3(speed * Time.deltaTime, 0, 0); if(tr.position.x < minPosX) < tr.position += new Vector3(addPosX, 0, 0); tr.GetChild(0).gameObject.SetActive(false); tr.DetachChildren(); AddSection(); >> > >

Точки в массив добавляем так, как показано на скриншоте:

Бесконечный раннер [платформер]

С начало лева, потом центральная, затем правая. Это важно для запуска процесса.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *