🎮 Что за модули в Unreal Engine и почему я должен о них париться?

Рассказываем о концепции модулей в Unreal Engine, зависимостях между ними, а также о том, как реализовывать модули, собирать их, загружать и использовать.

Модуль в Unreal Engine – это не более чем набор классов, который в собранном виде представлен в виде DLL. Не только любая игра на UE состоит из модулей, но и сам движок тоже, поэтому для любого UE-разработчика критически важно понимать, как работать с модулями.

Основные причины разделения кода на модули:

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

Код модулей

Перед тем как заниматься кодом модулей, необходимо рассказать, где он может быть расположен. Это зависит от предназначения модуля. Список потенциальных мест представлен ниже, где <PluginName> – имя плагина, <ModuleName> – имя модуля, <Game> – директория игры, <Groups> – опциональные директории для группировки модулей или плагинов:

  • Если модуль является частью движка: Engine/Source/<Groups>/<ModuleName>.
  • Если модуль является частью плагина движка: Engine/Plugins/<Groups>/<PluginName>/Source/<Groups>/<ModuleName>.
  • Если модуль является частью игрового плагина: <Game>/Plugins/<Groups>/<PluginName>/Source/<Groups>/<ModuleName>.
  • Если модуль является частью игры: <Game>/Source/<Groups>/<ModuleName>.
Примечание
О плагинах мы расскажем в следующей статье, но не спешите переходить по ссылке, т.к. она базируется на знаниях из этого текста, в связи с чем настоятельно рекомендуем прочитать материал о модулях первым. Всё, что необходимо знать о плагинах на данный момент, – это то, что каждый плагин может состоять из одного или более модулей.

Каждая из таких директорий может содержать две поддиректории: Public и Private. В Public должны находиться все хедеры, которые предназначены для использования другими модулями. В Private – всё остальное (файлы реализации и скрытые от внешнего кода хедеры). Чем меньше хедеров находится в Public, тем быстрее будет проходить процесс компиляции и линковки. Используйте также forward declaration [1], где это возможно.

На изображении ниже приведена структура одного из модулей (ActorPickerMode) движка:

Структура модуля ActorPickerMode.
Примечание
В некоторых модулях движка помимо Public/Private можно обнаружить директорию Classes. Когда-то она являлась единственным местом, где могли быть объявлены классы-наследники UObject, но теперь она deprecated и не несёт никакой дополнительной функциональности.

В одной из таких директорий (обычно в Private) должен находиться файл с реализацией модуля, по всеобщему соглашению называющийся <ModuleName>Module.cpp, где <ModuleName> – имя модуля. Его минимальное содержимое таково:

<ModuleName>Module.cpp
#include “ModuleNameModule.h”
#include “Modules/ModuleManager.h”
IMPLEMENT_MODULE(FDefaultModuleImpl, ModuleName)
  • Соответствующий хедер (<ModuleName>Module.h) может быть пустым.
  • Первым аргументом макроса IMPLEMENT_MODULE является имя класса, реализующего модуль. Это может быть ваш собственный класс (об этом варианте мы поговорим чуть позже) или “заглушка”, такая как FDefaultModuleImpl[2].
  • Второй аргумент – имя модуля. Обратите внимание, что макрос IMPLEMENT_MODULE должен быть расположен после всех объявлений в данном файле.
  • Если модуль является игровым, тогда вместо IMPLEMENT_MODULE необходимо использовать IMPLEMENT_GAME_MODULE. Если мы говорим о главном игровом модуле (primary game module), т.е. модуле, в котором движок располагает основной код игры, то необходимо использовать особый макрос IMPLEMENT_PRIMARY_GAME_MODULE.

Основной тип модулей, который вам, вероятно, придется создавать – это модули для плагинов, и они как раз используют IMPLEMENT_MODULE.

Примечание
При добавлении игровых модулей вручную существует несколько нюансов, которые должны быть соблюдены. Мы не будем на них останавливаться, а лишь приведем ссылку на официальную документацию.
Примечание
Зачастую никакие модули не зависят от главного игрового модуля, что в таком случае позволяет расположить весь код этого модуля прямо в корне, без разделения на Public/Private. Однако иногда от главного игрового модуля зависит модуль, содержащий тесты для него – в таком случае разделение имеет смысл.

При добавлении класса C++ через редактор движка пользователю будет предложено выбрать, в какой модуль добавить данный класс:

Добавление класса через UE Editor.

Сразу после загрузки модуля движком создаётся объект класса, реализующего модуль (того самого класса, который указывается в IMPLEMENT_MODULE), а после выгрузки данный объект уничтожается. Реализующий модуль класс должен быть унаследован от IModuleInterface [3], который содержит много полезных методов. Мы остановимся на двух самых важных:

  • void StartupModule() вызывается сразу после того, как DLL модуля была загружена и объект модуля был создан;
  • void ShutdownModule() вызывается перед тем, как DLL будет выгружена. После выполнения этого метода модуль больше недоступен к использованию.

Разумеется, модуль не должен быть ограничен только одним (главным) классом. Этот класс лишь служит точкой доступа к модулю, а также позволяет реализовать некоторую особую функциональность, связанную с движком. Вся же бизнес-логика должна быть вынесена в отдельные классы.

Чтобы получить доступ к модулю из любого другого модуля, необходимо вызвать следующий метод (не забудьте включить хедер “Modules/ModuleManager.h” [4]):

Получение доступа к модулю.
FModuleManager().Get().LoadModuleChecked(TEXT(“<ModuleName>”))

Этот метод возвращает модуль с именем <ModuleName>, если он загружен, а если нет – предварительно загружает. О других способах загружать модули мы поговорим далее в статье.

По умолчанию классы и методы из вашего модуля недоступны для использования внешним кодом, даже если хедеры с ними расположены в Public. Чтобы это исправить, существует несколько путей:

  • Отметить необходимые методы с помощью макроса <MODULENAME>_API, как в коде ниже:
Экспорт отдельного метода.
...

UFUNCTION(BlueprintCallable)

static MODULE_API void DoSomethingUseful();

...
  • Отметить класс с помощью макроса <MODULENAME>_API, в таком случае все методы класса становятся доступными для внешнего кода:
Экспорт всего класса.
...

UCLASS()

class MODULE_API USomeNativeFunctionLibrary : public UBlueprintFunctionLibrary

{

...
  • Использовать спецификатор MinimalAPI. Помеченные таким спецификатором классы можно наследовать, использовать при приведении типов, например, Cast<T> [5]. Также на объектах таких классов можно вызывать inline-методы.

Если не сделать ничего из этого, то при попытке внешнего кода использовать методы/классы из вашего модуля возникнут ошибки линковки.

Сборка модулей

Несмотря на то, что UE создаёт файлы SLN для проектов, процесс сборки полностью контролируется файлами .Build.cs- [7] и .Target.cs- [8]. Файл SLN нужен лишь для удобства пользователя при работе с кодом через поддерживающие этот формат IDE (например, Microsoft Visual Studio или JetBrains Rider for Unreal Engine).

К каждому модулю прилагается <ModuleName>.Build.cs – файл, в котором описываются особенности модуля, вроде зависимостей от других модулей. Любой модуль зависит от Core [9], так как использованный нами ранее ModuleManager.h находится именно там.

Зависимости позволяют модулям сообщать системе сборки UE о том, что для работы им требуются какие-либо другие модули.

Зависимости бывают двух видов:

  • Приватные: добавляются через список PrivateDependencyModuleNames.
  • Публичные: добавляются через список PublicDependencyModuleNames.

Пример файла .Build.cs:

Файл .Build.cs.
public class MyModule : ModuleRules
{
   public MyModule(ReadOnlyTargetRules Target) : base(Target)
   {
      PublicDependencyModuleNames.AddRange(new string[] {
          "Core"
        , "CoreUObject"
      });

      PrivateDependencyModuleNames.AddRange(new string[] {  });

      PublicIncludePaths.AddRange(
         new string[] {
            Path.Combine(ModuleDirectory, "Core/Game")
           , ModuleDirectory
       }
     );
   }
}

Добавление в любой из этих списков позволит вашему модулю использовать публичные хедеры из зависимого модуля (хедеры, включенные в список PublicIncludePaths), а также сообщит линкеру, что файлы .cpp зависимого модуля должны линковаться с вашим модулем.

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

Приватные и публичные зависимости между модулями.

Таким образом, при приватной зависимости модуля B от модуля C модуль A, зависящий от B (не важно как – приватно или публично), не будет видеть публичные хедеры C, в то время как при публичной – будет. Однако линковка A с C не произойдёт в любом случае.

Неправильно указанные зависимости выльются в ошибки компиляции и/или линковки.

Обратите внимание, что будут скомпилированы только те модули, которые находятся в дереве зависимостей. Если же от какого-то из модулей (например, от главного игрового модуля) ничего не зависит, но его необходимо скомпилировать, такой модуль нужно добавить в файл .Target.cs в список ExtraModuleNames:

Файл .Target.cs.
public class MyGameTarget : TargetRules
{
    public MyGameTarget (TargetInfo Target) : base(Target)
    {
        Type = TargetType.Game;
        ExtraModuleNames.Add("PrimaryGameModule");
    }
}

Как использовать модули

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

  • Указать модуль в разделе Modules в файле .uproject.
  • Указать модуль в разделе Modules в файле .uplugin.
  • Указать плагин в разделе Plugins в файле .uproject или .uplugin.

Синтаксис для первых двух вариантов идентичен:

Фрагмент файла .uproject или .uplugin.
"Modules": [
  {
     "Name": "<ModuleName>",
     "Type": "Runtime",
     "LoadingPhase": "Default"
  }
]

Поле Name предназначено для имени модуля. Type описывает, в каких билдах модуль будет загружен: например, Runtime – модуль будет загружен всегда, за исключением случая, когда сборка – программа (Program Target); Editor – только при сборке с редактором и т.д. Полный список возможных типов доступен в документации.

Примечание
Тип Developer помечен как deprecated, вместо него необходимо использовать UncookedOnly.

LoadingPhase описывает, когда во время загрузки движка будет загружен модуль: например, Default – в момент инициализации движка, PostEngineInit – после завершения инициализации. Полный список возможных фаз в документации.

Тип модуля и фаза загрузки не единственные свойства модулей, которые можно задать. Обратитесь к документации за полным списком.

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

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

Вывод

Весь Unreal Engine, как и любая игра на нём, состоит из модулей, каждый из которых представляет собой группу классов. Основные причины использования модулей – переиспользование и инкапсуляция. Сборка модуля управляется через соответствующий файл .Build.cs. Модули могут зависеть друг от друга и загружать друг друга. В следующей статье мы более подробно расскажем о создании и использовании плагинов.

Источники

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

matyushkin
29 марта 2020

ТОП-10 книг по C++: от новичка до профессионала

Книги по C++ на русском языке с лучшими оценками. Расставлены в порядке воз...
Библиотека программиста
23 июня 2017

Разработка игр – это просто: 12 этапов изучения геймдева

Разработка игр на плаву, она перспективна и набирает популярность. Мы подго...