Вступление
Всем привет. Меня зовут Ширшов Владимир и я техлид проекта King's Bounty 2. Игра разрабатывается на движке Unreal Engine 4.
King's Bounty 2 - это игра с открытым миром, в котором есть города, поселения, наполненные персонажами, интерактивными объектами и зонами сражений. Персонаж может перемещаться по миру как пешком, так и верхом. Все элементы открытого мира должны помещаться в память, удовлетворять требованиям по производительности и успевать загружаться на всех целевых платформах (PlayStation 4, XBox One, Nintendo Switch), чтобы вокруг игрока всегда были персонажи и детализированные объекты.
Я хочу поделиться опытом использования системы стриминга уровней в Unreal Engine 4 World Composition для создания открытого мира и рассказать об ее плюсах и минусах. Также расскажу о тех изменениях, которые нам потребовалось внести в нее для того, чтобы игра помещалось в память целевых платформ, и уровни успевали загружаться при перемещении игрока по локации.
Что такое World Composition
World Composition - это система стриминга уровней в Unreal Engine 4, которая предназначена для игр с открытым миром. World Composition используется для того, чтобы игра не давала сильной нагрузки на производительность, помещалась в заданные лимиты по памяти и при этом создавала ощущение того, что игрок находится в большом мире.
Если взять большой уровень, например, площадью в несколько квадратных километров, и загрузить его целиком, то это создаст большую нагрузку на:
CPU - лишние расчеты игровой логики, анимаций для дальних объектов;
рендер (GPU и CPU) - придется отображать большое количество геометрии, обрабатывать occlusion culling (отсечение геометрии перед рендером) для большого числа объектов, сильно вырастет количество Draw Calls (вызовов на отрисовку);
память - в ней придется держать огромное количество объектов, которые игрок даже не сможет увидеть в текущий момент, навигацию и персонажей, когда они находятся очень далеко от игрока.
Чтобы не обрабатывать большую локацию целиком, в Unreal Engine 4 есть возможность создавать дочерние уровни (Sublevels) для деления большого уровня на части. Управлять загрузкой саблевелов можно в ручном режиме. Это позволяет избежать проблем, описанных выше. Можно держать в памяти и обрабатывать только те части локации, которые в данный момент необходимы. Такой подход чаще используется для игр с линейными, но продолжительными локациями: когда по триггеру можно определить, что пора загрузить определенную часть уровня.
World Composition автоматизирует процесс загрузки и выгрузки уровней, реализует загрузку дочерних уровней в определенном радиусе вокруг игрока. Расстояние от дочернего уровня (чанка) до игрока рассчитывается по границе уровня (Level Bounds). Если граница уровня попадает в радиус стриминга, то этот уровень начинает загружаться. Если она находится дальше радиуса стриминга, то, если чанк загружен ранее, он будет выгружаться.Такой подход позволяет держать в памяти и обрабатывать только чанки и объекты рядом с игроком.
Однако, если оставить только уровни вокруг игрока, то за ними будет пустота. Конечно, можно пойти по заветам классических игр и спрятать все за туманом, как в Silent Hill. Но лучше дать возможность игроку видеть, что вдали его ждут города, замки, горы и леса. Для этого в World Composition существует система лодов (Уровней детализации, Level of detail, LOD) для чанков.
LOD чанка - это уровень, содержащий упрощенную геометрию, которая будет отображаться на дистанции вместо объектов чанка. Каждый чанк может иметь до четырех лодов. Для каждого из них есть возможность настроить дистанцию, на которой лод будет подменять основной чанк. Лоды можно создавать самостоятельно, либо воспользоваться инструментом World Composition для генерации лодов уровней.
Из всей геометрии на уровне формируется единый прокси меш, который будет отображаться на определенной дистанции вместо чанка. Детализацию прокси меша можно менять настройками для каждого из лодов. На ближние лоды выделить больше полигонов, выставить более высокое разрешение текстур и сделать материал сложнее. Для дальних лодов использовать меньше геометрии, простой материал и текстуры низкого разрешения.
Благодаря такому подходу все чанки, которые находятся вдали от игрока, занимают небольшую часть памяти и не оказывают значительного влияния на производительность за счёт малого полигонажа и простых материалов. Основные ресурсы системы выделяются на объекты, которые находятся рядом с игроком.
Для управления дистанцией загрузки уровней в World Composition существуют слои. Каждый чанк можно отнести к одному из слоев. При создании слоя есть возможность указать дистанцию, на которой уровни этого слоя будут загружаться. Например, требуется загружать ландшафт на большей дистанции, чем чанки с геометрией замков и поселений. В этом случае можно создать отдельный слой для ландшафта со своей дистанцией стриминга.
Плюсы
По общему описанию принципов работы можно выделить основные плюсы World Composition.
Функционал из коробки для разработки Open World игр;
Наличие инструментов и функционала для оптимизации производительности и памяти.
Это система, которая имеет минимально необходимые возможности и достаточно настроек для создания игры с открытым миром без написания собственных систем стримминга уровней.
А что внутри?
Прежде чем перейти к описанию минусов системы, я предлагаю ознакомиться с тем, как World Composition работает внутри. Это поможет эффективно использовать систему, выявить ее недостатки и понять, как их исправить. Я не буду приводить куски кода с комментариями, любой, у кого есть желание, может самостоятельно ознакомиться с исходниками. Главное - понять, что происходит под капотом.
Условно логику работы World Composition можно разделить на три части. Первая - это определение статуса уровня с учетом дистанции до игрока (нужно ли загрузить и показать или нужно скрыть и выгрузить чанк). Вторая - обновление стейт машины каждого чанка и определение его нового состояния. Третья - обработка текущего состояния уровня.
На первом этапе происходит проверка дистанции до игрока (детали можно посмотреть в файле WorldComposition.cpp), а точнее, дистанции до камеры. Каждый тик World Composition проверяет пересечение бокса, который построен на основании Level Bounds чанка и вытянут вверх и вниз на MaxFloat, со сферой (не спрашивайте, почему бы не считать пересечение прямоугольника с кругом). Центром сферы является точка, которая запрашивается у PlayerController и по факту является положением камеры. Радиус сферы - это дистанция стриминга для слоя, которому принадлежит данный чанк. Каждый кадр данный расчет выполняется для всех уровней World Composition.
По результатам проверки дистанции для чанка устанавливаются флаги, нужно ли его загрузить и отобразить и какой LOD чанка сейчас использовать.
После проверки дистанции до игрока в World.cpp выполняется обновление состояния всех инстансов World Composition, в них хранятся ссылки на загруженные уровни и информация о текущем и целевом состоянии чанка. Для каждого чанка существует стейт машина, которая описывает его текущее состояние и определяет, к какому состоянию требуется перейти в зависимости от тех или иных условий.
Состояния чанка:
Unloaded - уровень не отображается, ресурсы уровня должны быть выгружены из памяти;
Loading - ресурсы чанка поставлены на асинхронную загрузку;
LoadedNotVisible - ресурсы чанка загружены, чанк не отображается;
MakingVisible - уровень полностью загружен, экторы уровня регистрируются или ожидают регистрации;
MakingInvisible - уровень не отображается, экторы на уровне ожидают дерегистрации компонентов;
LoadedVisible - уровень полностью загружен и отображается, все экторы зарегистрированы.
После того, как определено текущее состояние уровня, происходит обработка этого состояния. Наибольший интерес для нас представляют добавление уровня в мир (MakingVisible) и удаление из мира (MakingInvisible).
Добавление чанка в мир (World.cpp - AddToWorld). В тике каждый чанк, который должен быть добавлен в мир, выполняет попытку регистрации всех экторов уровня. В UWorld хранится ссылка на уровень, который сейчас добавляется, и пока он не закончит эту процедуру, то другие уровни не будут добавлены в мир. За один тик происходит регистрация только части экторов на чанке. С помощью настроек в конфиге можно регулировать время, которое выделяется на эту операцию (параметр s.LevelStreamingActorsUpdateTimeLimit). В рамках одного кадра экторы регистрируют компоненты до тех пор, пока не исчерпают лимит по времени. Но время регистрации одного эктора может выйти за временные лимиты. Это значит, если в конфиге указать, что на добавление уровня в мир выделено 2мс, то добавление этого уровня оборвется, когда время регистрации экторов превысит 2мс, и продолжится в следующем тике. В настройках можно задать гранулярность регистрации компонентов (параметр s.LevelStreamingComponentsRegistrationGranularity), но за тик пройдет регистрация не менее чем одного эктора. Из названия параметра складывается ощущение, что именно такое количество компонентов за итерацию будет зарегистрировано при добавлении уровня в мир. На самом деле эктор регистрируется целиком, после чего количество компонентов в нем используется для проверки, превышен ли лимит зарегистрированных компонентов за итерацию. Если нет, то начинается регистрация следующего эктора, если да, то идёт проверка, сколько времени затрачено на регистрацию. Если лимит по времени не исчерпан, стартует еще одна итерация регистрации экторов, а счётчик компонентов сбрасывается.
Удаление уровня работает аналогично, но вместо регистрации вызывается дерегистрация (Unregister) у всех компонентов экторов (параметры для настройки: s.UnregisterComponentsTimeLimit и s.LevelStreamingComponentsUnregistrationGranularity соответственно).
Для того, чтобы уровни добавлялись в мир и удалялись из него без существенного влияния на производительность, нужно следить за тем, чтобы экторы на уровне не содержали большое число компонентов и регистрация экторов проходила достаточно быстро. Чтобы чанк успевал добавиться в мир за адекватное количество кадров, нужно следить за количеством экторов на чанке.
Адекватное количество кадров на добавление уровня в мир зависит от скорости перемещения игрока и целевого FPS, а количество компонентов и максимальное время регистрации эктора зависит от целевого железа и желаемой произвольности. Универсальных цифр здесь нет. Например, на уровне находится 1000 экторов по 5 компонентов в каждом, за кадр мы успеваем зарегистрировать 5 экторов, это значит, что при 30 FPS потребуется примерно 7 секунд на добавление такого чанка. Если в вашей игре чанки не успевают появляться рядом с игроком, причина может быть не только в том, что ресурсы не успели загрузиться в память, но и в том, что CPU не успел обработать экторы и компоненты на чанке. Аналогично происходит удаление уровня из мира, до тех пор, пока не прошла дерегистрация всех компонентов, ресурсы уровня не будут выгружены из памяти.
Лимиты по времени на регистрацию и дерегистрацию рассчитываются в контексте каждого уровня отдельно. Это значит, что если в тике один уровень закончил добавление в мир, то следующий за ним в массиве уровень успеет начать регистрацию в тот же тик, а значит время на регистрацию может выйти за указанные в конфиге лимиты.
Особенности, которые следует учитывать и минусы World Composition
Из описанных выше принципов работы можно определить особенности, которые следует учитывать при использовании World Composition, и понять, какие недостатки в нем существуют.
Стриминг по позиции камеры. Для игры от первого лица это не существенно, а вот для игры от третьего лица возникает ситуация, когда игрок находится на месте, поворачивает камеру, а уровни на границе дистанции стриминга начинают загружаться и выгружаться.
Лимиты на время добавления и удаления уровней это не жесткие лимиты, которые не будут превышены. Лимиты говорят только о том, что добавление/удаление уровня в этом тике будет приостановлено при их превышении. За тик обрабатывается минимум один эктор, даже если его регистрация/дерегистрация превысит ограничения по времени.
Переходы между состояниями чанка прописаны определенным образом. Если начался процесс загрузки уровня, то ресурсы чанка будут загружены, даже если игрок решил пойти в другую сторону и уровень больше не нужен. Сперва уровень полностью загрузится, только потом его ресурсы начинают выгружаться из памяти.
Аналогично состояния удаления и добавления. Если уровень находятся в стейте на удаление, но игрок успел к нему вернуться, то уровень сперва будет удален, а потом повторно добавлен в мир. Выглядят так, что игрок прибегает в полностью загруженную локацию, потом чанк заменяется на прокси меш, затем повторно появляется детальный уровень.
Уровни добавляются и удаляются из мира в том порядке, в каком они находятся в массиве чанков, который сформировался в редакторе. И уровень, находящийся ближе к игроку может начать добавляться в мир позднее, чем уровень, который расположен дальше. Если чанки довольно тяжелые на добавление и удаление, они не будут успевать своевременно выполнять эти операции при быстром движении игрока (например, первым добавится уровень, который находится от игрока дальше, а уровень, который рядом появится потом).
Нужно следить за границами чанков. Некорректно расположенный эктор может значительно увеличить Level Bounds, из-за чего уровень загрузится раньше, чем нужно. Слишком большие границы чанка могут привести к излишнему потреблению памяти и помешать стриммингу ближних уровней. При расчете LevelBounds есть особенности, которые могут вытянуть один из углов границы чанка в центр мира, в координаты (0, 0, 0). Например, если на чанке есть система частиц, а в редакторе не включен Realtime (опция в настройках вьюпорта), то BoundingBox для такой системы частиц будет в мировом центре координат.
Иногда из-за человеческой ошибки эктор может оказаться не на том чанке, где должен, что увеличит размер Level Bounds.
Уровни загружаются с равным приоритетом. Чанки, которые ближе к игроку или содержат более важные геймплейные или визуальные элементы, будут загружаться в память с тем же приоритетом, что и чанки, которые находятся дальше от игрока или менее значимы.
К минусам World Composition можно добавить ряд неудобных моментов в редакторе. Например, невозможность автоматически разделить созданный ландшафт на чанки, или отсутствие автоматического распределение экторов по чанкам во время редактирования, чтобы избежать человеческой ошибки при размещении экторов на уровне.
Что можно сделать лучше?
В процессе разработки King's Bounty 2 мы столкнулись с тем, что игрок попадал в лоды чанков при быстром перемещении (т.е. уровни не успевали загружаться и добавляться в мир). Особенно часто это возникало при движении в сторону крупных точек интереса (города, замки) с большим количеством персонажей и объектов окружения. Основные причины - ресурсы не успевали загружаться в память с диска, CPU не успевал обрабатывать большое количество компонентов и добавлять их в мир. Кроме того, крупные точки интереса создавали пиковую нагрузку на CPU при движении игрока и приводили к большому потреблению памяти.
Описанные ниже решения, к сожалению, по большей степени невозможны без модификации исходников движка, либо написания собственной системы стриминга. Если вы только начинаете свой проект, я бы рекомендовал рассмотреть второй вариант.
Самое первое, что мы сделали - добавили возможность через Player Controller указывать произвольную точку, относительно которой считается дистанция до чанков. Вместо камеры используется позиция персонажа игрока, во время боя - центр боевой арены. В нашей игре это особенно актуально, потому что кроме вида от третьего лица есть пошаговые бои. Во время такого боя камера может быстро перемещаться на десятки метров, но стримминг должен идти от центра боевой зоны.
Во-вторых, мы решили, что не все элементы уровня одинаково полезны. На локациях находятся крупные объекты (стены замков, дома, башни и т.д.), средние объекты (бочки, заборы, мебель) и мелкие объекты (вещи на прилавках торговцев, кувшины, кружки и прочее). Мелкие объекты в играх часто скрывают по дистанции, через настройку дальности видимости, но такие объекты все еще остаются в памяти и влияют на скорость стриминга. Мы решили разбить наши уровни на три части (часть с крупными, часть со средними и часть с мелкими объектами) и сделать разную дистанцию загрузки для каждой части уровня. Если использовать для данного подхода слои World Composition, то получится, что количество чанков станет в три раза больше, хотя по факту, три слоя для одного чанка имеют схожий Level Bounds и дистанция до игрока для них будет примерно одинаковая. В таком случае нагрузка на CPU будет выше, хотя смысла в лишних расчетах дистанции нет. Чтобы уменьшить расчеты, мы сделали систему с родительскими и дочерними уровнями. Родительским является уровень с крупной геометрией, дочерние - чанки с мелкими и средними объектами. Дистанция рассчитывается один раз до родительского уровня, и в зависимости от нее принимается решение о том, какие дочерние уровни нужно отправить на загрузку, а какие на выгрузку.
Дочерние уровни также используются для приоритезации загрузки. Чанки с крупной геометрией имеют высокий приоритет загрузки, дочерние уровни загружаются с меньшим приоритетом.
Добавление чанков в мир происходит тоже с приоритетами. Первыми добавляются в мир ближайшие к игроку чанки с крупной геометрией, а последними добавляются дальние чанки с мелкими объектами. Удаление и добавление в мир мы доработали таким образом, чтобы в тик не обрабатывалась больше одного уровня на операцию. Например, если в тике закончилось добавление уровня в мир, то следующий чанк начнет добавляться только в следующем тике.
Благодаря такой системе приоритетов мы сохраняем детализацию уровней, и чанки успевают загружаться и добавляться в мир при перемещении игрока верхом. Даже если какой-то из уровней с мелкими объектами не успеет отобразиться вовремя, игрок все равно не попадет в лоды чанков.
Кроме приоритета по дистанции до игрока мы добавили возможность учитывать направление его движения, чтобы раньше загружать и добавлять в мир чанки, в направлении которых движется игрок. Но в итоге такой функционал не пригодился.
Все эти модификации World Composition не только увеличивают скорость стриминга, но также приводят к уменьшению потребления памяти и снижают нагрузку на рендер, так как мелкие и средние объекты появляются на меньшей дистанции от игрока.
Следующим шагом мы модифицировали стейт машину LevelStreaming. Чтобы избавиться от выполнения лишних операций, мы добавили дополнительные переходы между состояниями.
Например, из состояния MakingVisible появилась возможность перейти в состояние LoadedNotVisible, если для уровня не началась регистрация экторов, и дистанция до него стала больше дистанции для загрузки. Когда чанк загружен, но не добавлен в мир, то его можно сразу отправить на удаление, если игрок успел от него отдалиться. Аналогично с удалением уровня, если чанк не успел начать дерегистрировать экторов в состоянии MakingInvisible, то его можно перевести в состояние LoadedVisible, если игрок повторно приблизился к чанку.
Такие ситуации часто возникают на периферийных чанках (например 2х3, 6х7, 7х6 на изображении). На схеме игрок движется так, что эти уровни попали в область стриминга, но сразу из нее выйдут при постоянном движении игрока в направлении стрелки. В оригинальной стейт машине эти чанки все равно были бы отправлены на загрузку и на добавление в мир, хотя по факту они уже не нужны.
Наша модификация стейт машины не только ускоряет стримминг, но и избавляет от проблем с тем, что при быстром удалении от уровня и приближении к нему происходит лишнее переключение чанка, т.е. уровень уже отмечен на удаление и скроется, а затем моментально покажется снова, потому что игрок к нему уже вернулся.
Чтобы уменьшить нагрузку на чтение ассетов с диска, мы добавили отмену запросов на асинхронную загрузку тех уровней, загрузка которых уже не актуальна. Особенно это заметно также на периферийных чанках, которые находятся на границе дистанции стриминга. Эти уровни могут быстро перейти из состояния Loading в состояние Unloaded, потому что их ресурсы больше не нужно загружать. Исключение из асинхронной загрузки чанков, которые больше не нужны, позволяет быстрее загружать нужные уровни.
Кроме модификаций рантаймовой части World Composition, мы провели ряд улучшений в редакторе, для того, чтобы разработчикам было удобнее работать с локациями.
Во-первых, добавили автоматическое размещение экторов на чанк. Во время работы с уровнем по координатам эктора автоматически определяется, к какому из чанков он должен принадлежать. Когда эктора размещают на уровне или ему меняют позицию, он автоматически будет перемещен на правильный чанк. Это позволяет уменьшить фактор человеческой ошибки, когда эктора случайно разместили не на тот чанк, тем самым увеличили LevelBounds.
Во-вторых, мы автоматизировали процесс создания слоев. Через командлет происходит автоматическое деление уровня на слои по указанным критериям. В итоге из одного чанка получается три уровня с разными типами экторов (крупные, средние и мелкие).
В-третьих, автоматизации подверглась генерация лодов чанков. Никому не приходится вручную актуализировать лоды после редактирования уровней, весь процесс происходит на сервере.
В-четвертых, мы добавили возможность одновременно работать над локацией сотрудникам из разных отделов. Очень часто работа на локации ведётся сразу несколькими отделами: левел-артисты расставляют на уровне статику, геймдизайнеры располагают интерактивные объекты, художник по освещению настраивает свет и зоны освещения, художники по VFX размещают различные эффекты, звуковой отдел настраивает звуковые зоны и точечные источники звуков.
Проблема при одновременной работе с локацией нескольких отделов заключается в том, что нет возможности смержить работу разных людей. Изменения, которые делает один человек, при заливке в систему контроля версий могут затереть изменения, которые внес в это же время другой человек. Чтобы избавиться от данной проблемы, каждый из отделов работает на своих уровнях и заливает только их. Т.е. сущствуют отдельные уровни под окружение, звуки, освещение, VFX и т.д.
Каждую ночь на сервере происходит обработка чанков, и экторы с разных уровней перемещаются по сетке на соответствующие чанки, и в билд попадают уровни, которые содержат работу всех отделов.
Кроме того, ночами на серверах рассчитывается статическая навигация и происходит ряд оптимизационных работ (генерация Instanced Static Mesh, корректировка Level Bounds и т.д.).
Дополнение
В дополнении к основной теме хочется упомянуть о важном факторе, который влияет на скорость загрузки ресурсов. Одна из ключевых задач, которую мы решали в рамках работы с World Composition, это повышение скорости стриминга уровней. Как улучшить World Composition, я описал выше. Ещё есть способ ускорить чтение ресурсов с диска. По умолчанию при упаковке игры в pak файл, ассеты записываются в него в случайном порядке. Когда требуется загрузить тот или иной уровень, его ресурсы будут считываться из разных областей диска, что существенно уменьшает скорость загрузки. Для избежания этой проблемы, можно отсортировать ассеты внутри pak файла таким образом, чтобы ассеты, которые требуются в одно время, находилась рядом друг с другом.
Подробнее о том, как отсортировать pak файл, можно прочитать в официальной документации: https://docs.unrealengine.com/4.26/en-US/Basics/Projects/Packaging/ раздел "Ordering your pak file".
Хорошо отсортированный pak файл позволяет существенно сократить время загрузки ресурсов. Для игры с открытым миром идеальный порядок файлов сделать не получится, так как игрок может двигаться в любом направлении, но сортировка все равно дает ощутимый прирост к скорости загрузки ресурсов. При записи fileopenlog (порядок открытия файлов в игре) лучше исключить загрузку ресурсов, которые подгружаются отдельно от стриминга локаций или время загрузки которых не критично для игрового процесса. В нашем случае при записи мы исключили данные для боев, катсцен и UI. Благодаря чему в pak файле рядом друг с другом оказались ресурсы чанков, персонажей и интерактивных объектов.
Заключение
Статья создавалась не только с целью рассказать о возможностях инструмента World Composition, его плюсах и минусах. Основной целью было показать, насколько важно разобраться, как работать с определенной системой, и понять, как эта система устроена изнутри. Такой подход позволяет эффективнее использовать инструменты, которые предоставляет движок и, при необходимости, принимать решения о модификации этих инструментов или делать выбор в пользу написания собственных систем для решения тех или иных задач.
В итоге, с помощью внесённых изменений мы смогли добиться необходимой скорости загрузки чанков, улучшить производительность и не превысить лимиты по памяти на всех целевых платформах. А дополнительные работы по автоматизации сильно упростили процесс разработки.
P.S. За время, пока я писал эту статью, вышел в ранний доступ Unreal Engine 5. В нем появилась замена World Composition под названием World Partition. Подробный разбор World Partition имеет смысл делать, когда движок выйдет из раннего доступа. В данный момент у World Partition имеется ряд багов, и сама система пока не в финальном виде. Но я был приятно удивлен, когда увидел, что те идеи, которые мы реализовали, модифицируя World Composition, присутствуют в World Partition (я даже нашел куски кода, которые один в один как наши изменения). В World Partition сделана загрузка уровней и добавление их в мир по приоритетам, в зависимости от дистанции до игрока и от направления, в котором сейчас смотрит камера. В World Partition стейт машина чанка модифицирована по аналогии с тем, как сделали мы. Есть функционал в редакторе для автоматического размещения экторов на чанки по сетке и командлет для конвертации обычного уровня в уровень с World Partition. Приятно знать, что наша разработка велась в правильном направлении, что также подтверждают результаты тестов на целевом железе.
P.P.S. Хочу поблагодарить Константина Якушенко за очень полезные доклады о портировании Sinking City на PlayStation 4, XBox One и Nintendo Switch. За идеи задавать произвольную позицию точки стриминга и приоритезировать загрузку уровней.