Как устроен Unreal Engine: не API, а инженерное мышление
Это не статья о том, «как написать игру на Unreal». Это статья о том, почему движок вообще устроен именно так, как устроен — и если ты действительно поймёшь ход рассуждения ниже, ты сможешь сам предсказать архитектуру любой ещё не изученной тобой системы движка, вместо того чтобы каждый раз заучивать её заново.
01Что это?
Unreal Engine — не одна вещь, а слоёный пирог из нескольких разных инженерных решений, каждое из которых закрывает свою отдельную проблему: Engine (сам движок как программа и как готовая инфраструктура), Framework (модель управления, в которую встраивается твой код), UObject (объектная модель, общая для C++ и редактора), Reflection (способность движка «видеть» устройство твоих классов в рантайме) и Garbage Collector (автоматическое управление временем жизни объектов). Эта статья показывает, что все пять — не случайный набор фич, а одна цепочка причин и следствий: каждое следующее решение существует, потому что предыдущее создаёт проблему, которую нужно решить. Пройдя эту цепочку один раз осознанно, ты получаешь не пять фактов для запоминания, а один способ мышления, применимый к любой другой системе движка.
02Почему это существует?
Проблема: 1997 год, студия делает шутер
Представь команду, которая в 1997 году садится делать трёхмерный шутер. Чтобы игра вообще запустилась и на что-то отреагировала, нужно с нуля написать: отрисовку 3D-графики поверх видеокарты конкретного производителя, загрузчик 3D-моделей и текстур из файлов на диске, обработку ввода с клавиатуры и мыши, физику столкновений и гравитацию, звуковой движок, сетевой код для мультиплеера с учётом задержек сети, редактор уровней, чтобы дизайнеры вообще могли расставлять объекты не строчками кода, а мышкой, и систему сохранения/загрузки состояния игры. Ни одна из этих восьми вещей не имеет отношения к тому, что делает игру именно ЭТОЙ игрой, а не какой-то другой — это чистая инфраструктура, без которой игра физически не запустится, но которая сама по себе не является геймплеем.
Команда тратит подавляющую часть времени на переизобретение рендера, загрузчика ассетов и редактора — то есть на вещи, которые не отличают одну игру от другой, — и только небольшой остаток на то, что игроки реально видят и ощущают: баланс оружия, левелдизайн, тайминг анимаций, ощущение от стрельбы.
Причина: инфраструктура не переносится между проектами сама по себе
Проблема не в том, что инфраструктуру писать сложно (хотя это тоже так) — проблема в том, что знания о том, как её писать, не переносятся структурно между проектами. Если у студии нет общего движка, следующая игра снова начинается почти с нуля: даже если тот же программист, который писал рендер для первой игры, пишет рендер для второй, он пишет его заново, потому что нет общего, переиспользуемого куска кода — есть только опыт в голове одного человека, который трудно передать команде и невозможно быстро адаптировать под новую задачу.
Первая попытка решения — купить отдельные независимые библиотеки
Естественный следующий шаг — не писать всё с нуля, а взять готовые сторонние решения: сторонний рендер, сторонний физический движок вроде раннего PhysX, сторонний звуковой SDK — и написать свой код, который дёргает каждую библиотеку по отдельности.
Каждая такая библиотека — это просто набор функций, которые ты вызываешь. Библиотека ничего не знает про твои игровые объекты: она не знает, что такое «актор», что игрок только что вошёл на уровень или подобрал предмет. Клей между несколькими независимыми библиотеками — общая модель игровых объектов, редактор, который умеет работать со всеми системами разом, система сохранения состояния, синхронизация всего этого по сети — снова приходится писать самому, и именно эта склейка оказывается самой сложной частью работы, а не самой простой, потому что теперь нужно согласовывать чужие, не спроектированные друг под друга API.
03Какую проблему решает?
Ответ Epic на эту проблему — не «более удобная библиотека», а смена самой модели отношений между твоим кодом и инфраструктурой. Вместо того чтобы ты вызывал набор независимых библиотек и сам был точкой сборки всего проекта, движок сам становится точкой сборки: он владеет главным циклом программы, владеет общей моделью объектов, владеет редактором, владеет системой загрузки/сохранения — а твой код становится набором классов-расширений, которые движок использует в предсказуемых, заранее определённых местах. Это решает исходную проблему не «более быстрым письмом инфраструктуры», а тем, что подавляющую часть инфраструктуры вообще не нужно писать заново — Epic написала её один раз, и теперь она переиспользуется в каждом проекте на движке, а команда действительно тратит время на то, что отличает её игру от других.
04Как устроено?
Что такое Engine, и почему этого недостаточно для ответа на вопрос «что такое Unreal»
Слово «Engine» (движок) само по себе описывает только часть картины: набор технических подсистем, которые совместно превращают данные (модели, текстуры, звуки, код) в работающую интерактивную симуляцию — рендер, физика, звук, сеть, система ассетов. Это уже само по себе огромная инженерная работа, но если бы Unreal был «только» набором таких подсистем, доступных через API, он бы оставался тем самым набором независимых библиотек из раздела 2, просто написанным одной компанией, а не собранным из разных источников. То, что превращает набор технических подсистем в целостную систему, с которой удобно работать команде разработчиков — это не про рендер и не про физику, а про то, кто кем управляет. Именно на этот вопрос отвечает следующее понятие — Framework.
Что такое Framework — и почему Unreal Engine именно фреймворк, а не библиотека
Разграничение между библиотекой и фреймворком лежит отнюдь не в плоскости размера программного кода, количества предоставляемых функций или степени сложности API — один из наиболее показательных критериев такого разграничения, хотя и не единственный, как будет уточнено ниже, состоит в том, кому принадлежит управление архитектурой выполнения программы и кто, в конечном счёте, принимает решение о моменте вызова пользовательского кода.
В ситуации, когда разработчик обращается к обычной библиотеке (например, к утилите для чтения PNG-файлов), архитектурный контроль полностью сохраняется за ним: он располагает собственной функцией main(), самостоятельно организует главный цикл приложения и в произвольно выбранный момент времени инициирует вызов необходимой библиотечной функции — «загрузить файл», «обработать массив данных», «возвратить результат». Библиотека, в свою очередь, выступает исключительно как поставщик готовой функциональности, пребывая в пассивном ожидании обращения со стороны клиентского кода; она не обладает ни знанием о структуре вызывающего приложения, ни полномочиями вмешиваться в его жизненный цикл.
С фреймворком складывается диаметрально противоположная картина: он предоставляет разработчику не разрозненный набор утилит, а целостный каркас приложения, включающий в себя правила организации кодовой базы, предопределённую последовательность инициализации и уничтожения объектов, систему межкомпонентного взаимодействия, а также заранее специфицированные точки расширения, в которые пользовательский код может быть интегрирован. Именно к этому классу систем и относится Unreal Engine. Когда программист создаёт класс-наследник, скажем, AActor, он не конструирует собственный игровой цикл — точка входа программы жёстко закреплена за исполняемым модулем движка, тогда как его код оказывается встроенным в уже существующий жизненный цикл посредством реализации функций с фиксированными сигнатурами — BeginPlay, Tick, EndPlay; при этом движок самостоятельно и в строго определённом им самим порядке решает, когда именно следует вызывать каждую из этих функций. Тем самым метод, написанный разработчиком, превращается не в точку входа, а в точку расширения, активируемую внешней средой исполнения.
Данный архитектурный подход носит наименование инверсии управления (Inversion of Control, IoC) и заключается в том, что основной поток выполнения программы переходит из ведения пользовательского кода в ведение фреймворка, который диктует как последовательность, так и временны́е параметры вызова подключаемых модулей.
Обычная программа с библиотекой (управление у тебя):
int main() {
Init();
while (running) {
ProcessInput();
Update();
Render();
}
}
→ Главный цикл определяется разработчиком.
→ Порядок выполнения подсистем устанавливается им же.
→ Библиотека не инициирует вызовов;
обращение к ней происходит по инициативе клиентского кода.
Класс внутри Unreal Engine (управление у фреймворка):
void ASA_Character::BeginPlay()
{
/* твой код */
}
void ASA_Character::Tick(float DeltaTime)
{
/* твой код */
}
→ main() закреплена за движком.
→ Unreal управляет созданием объектов и их временем жизни.
→ Движок определяет, когда активировать BeginPlay,
с какой периодичностью вызывать Tick
и в какой момент произвести финализацию объекта.
→ По завершении пользовательского кода движок продолжает
свой цикл, последовательно обновляя физику, сеть,
рендеринг и сопутствующие подсистемы.
→ Следовательно, пользовательская функция есть
точка расширения, но отнюдь не точка входа.
int main() { ... } — это специальная функция, с которой в обычной C++ программе начинается выполнение: когда программу запускают, первым делом вызывается именно она, а не что-либо другое. while (running) { ... } — это цикл: пока условие в скобках (здесь — переменная running) истинно, код внутри фигурных скобок выполняется снова и снова, строка за строкой, а не один раз.
void ASA_Character::BeginPlay() — двойное двоеточие здесь используется иначе, чем при обращении к уже существующей функции: это способ написать реализацию метода вне тела класса — сказать «вот код функции BeginPlay, которая была объявлена внутри класса ASA_Character», не переписывая объявление класса целиком. float DeltaTime в скобках у Tick — параметр: значение, которое функции передаёт вызывающий код (в данном случае — сам движок, передающий длительность прошедшего кадра в секундах), доступное дальше внутри функции как обычная переменная.
Инверсия управления не является архитектурным излишеством или данью эстетике — она представляет собой вынужденное и единственно жизнеспособное решение, позволяющее одному и тому же исполняемому ядру движка обслуживать тысячи разнородных проектов, каждый из которых оперирует собственной, уникальной игровой логикой.
Если бы каждый отдельный проект брал на себя ответственность за управление порядком запуска подсистем, разработчикам пришлось бы вручную определять моменты создания игрового мира, инициализации физического движка, рендеринга, сетевых соединений и прочих инфраструктурных компонентов; при этом любая ошибка в выстраивании данной последовательности — например, попытка обращения к ещё не загруженному уровню или к неинициализированному объекту — неминуемо приводила бы к нестабильности и, в худшем случае, к аварийному завершению приложения.
Когда же жизненным циклом приложения в полном объёме владеет движок, он гарантирует единообразный и корректный порядок инициализации для всех проектов без исключения; разработчик, в свою очередь, работает внутри уже подготовленной, проверенной архитектуры и его задача сужается до заполнения конкретных, заранее предусмотренных точек расширения, не затрагивая критических системных механизмов.
Хотя инверсия управления является одним из наиболее характерных признаков современных фреймворков, особенно тех, что берут на себя управление жизненным циклом приложения, следует понимать, что IoC не представляет собой единственно возможного и обязательного критерия для отнесения программной системы к классу фреймворков.
Существует значительное число фреймворков, которые предоставляют разработчику сквозные системные возможности — такие как управление динамической памятью, рефлексию, сериализацию, событийную модель, систему сборки или жёсткие архитектурные паттерны — но при этом оставляют функцию main() под полным контролем клиентского кода, не навязывая ему собственную модель исполнения.
Таким образом, более корректным представляется понимание фреймворка не как «системы, непременно захватывающей управление», а как архитектурной основы, предоставляющей общий инфраструктурный слой поверх обычного прикладного кода, структурирующей его и облегчающей решение типовых задач.
В случае с Unreal Engine инверсия управления выступает одним из центральных механизмов архитектуры: движок прочно удерживает главный цикл, полностью контролирует время жизни объектов и вызывает пользовательские реализации через заранее определённые точки расширения, что и позволяет квалифицировать Unreal не как обширную коллекцию функций, а именно как полноценный фреймворк, диктующий собственную модель выполнения приложения.
Почему обычный C++ выбран как основа — и почему его одного недостаточно
Ограничение №1: бюджет кадра
Игра в реальном времени должна успевать пересчитать физику, ИИ, анимацию, рендер и игровую логику за один кадр. При 60 кадрах в секунду это 16.6 миллисекунды суммарно на всё перечисленное. Языки с автоматическим управлением памятью через сборщик мусора, который может в любой непредсказуемый момент «встать на паузу» и просканировать всю кучу (классическая модель ранней Java, а также большинства реализаций управляемых рантаймов) — вносят непредсказуемые паузы ровно тогда, когда сборщику заблагорассудится это сделать. Это недопустимо, когда бюджет кадра и так на пределе: одна такая пауза в середине боевой сцены — это заметный игроку рывок кадровой частоты (так называемый micro-stutter). C++ даёт прямой контроль над временем жизни объектов и раскладкой данных в памяти, необходимый для того, чтобы удерживать этот бюджет предсказуемо, кадр за кадром.
Ограничение №2: тысячи взаимно ссылающихся объектов, которыми управляет не только программист
Ручное управление памятью на C++ (собственные new/delete для каждого игрового объекта) в проекте, где тысячи акторов держат указатели друг на друга — актор держит указатель на цель, компонент держит указатель на владельца, AI держит указатель на последнего замеченного врага — это прямой источник постоянных use-after-free багов, если управлять этим вручную. Проблема усложняется тем, что в Unreal объекты создаются и удаляются не только твоим кодом, но и редактором, и системой загрузки уровней, и сетевым кодом при отключении игрока — то есть ты, как автор конкретного класса, физически не можешь проследить все места, откуда на твой объект может прийти указатель.
Вывод из двух этих ограничений одновременно: нужен язык с ручным контролем над временем выполнения (значит — C++, а не управляемый язык с непредсказуемым GC), но при этом нужна защита от use-after-free для объектов, на которые ссылаются множество разных, не связанных друг с другом систем движка (значит — какая-то форма автоматического управления временем жизни, но только для этой конкретной категории объектов, а не для вообще всей памяти программы). Это ограничение — прямая причина всего, что описано дальше в этой статье: Unreal не выбирает между «быстрый, но опасный C++» и «безопасный, но с паузами managed-язык» — он строит третий вариант: производительный C++ как ядро, и тонкую, предсказуемую надстройку сборки мусора только для одной конкретной категории объектов (об этом — раздел про Garbage Collector).
Reflection и Garbage Collector в Unreal — не изобретение современной версии движка. Ранние версии движка (Unreal, 1998 год, и последующие Unreal Engine 2 и 3) использовали для игровой логики отдельный язык — UnrealScript: интерпретируемый, со встроенной сборкой мусора и рефлексией с самого начала, в то время как низкоуровневое ядро движка (рендер, физика) писалось на C++. Unreal Engine 4 (2014 год) убрал UnrealScript как отдельный язык и перенёс его роль на «настоящий» C++, дополненный генерацией кода через Unreal Header Tool — но сохранил ту же самую идею: игровые объекты должны быть управляемы, отражаемы и удобны для написания на лету, даже если ядро движка остаётся низкоуровневым и без этих накладных расходов. Другими словами, Reflection и GC в современном Unreal C++ — это не новая идея, добавленная поверх «обычного» C++, а перенос почти двадцатилетней проверенной архитектуры на новый, более производительный язык реализации.
Почему появился UObject
Ограничение из предыдущего раздела формулирует задачу: нужна отдельная категория объектов, для которых движок берёт на себя автоматическое управление временем жизни, интеграцию с редактором и сериализацию — но при этом основная масса кода движка (математика, контейнеры, низкоуровневые системы) остаётся обычным, «голым» C++ без этих накладных расходов. Ответ Epic — ввести явную границу: любой класс, которому нужны эти возможности, наследуется от общего базового класса UObject, и именно этот факт наследования — сигнал для всей остальной инфраструктуры движка (сборщика мусора, редактора, системы сериализации), что с этим конкретным объектом нужно обращаться по особым правилам. Обычные C++ классы, не отвечающие этим требованиям (например, простая математическая структура вроде вектора), остаются вне этой системы — и не платят за возможности, которые им не нужны.
UObject — это не «базовый класс для всего в Unreal». Это осознанно проведённая граница: по одну сторону — объекты, которыми управляет движковая инфраструктура (жизненный цикл, GC, редактор, сеть, сериализация), по другую — обычный C++ без всего этого. Решение о том, наследоваться ли от UObject, — архитектурное решение с реальной ценой (см. раздел о влиянии на память и производительность ниже), а не автоматический выбор по умолчанию для каждого нового класса.
Подробный разбор внутреннего устройства UObject — конструктора, Class Default Object, разницы между UObject и AActor — вынесен в отдельную статью (см. блок «что изучить дальше» в конце), потому что тема достаточно объёмная, чтобы заслуживать собственного полного разбора по этому же стандарту. Здесь важно зафиксировать только одно: UObject — это ответ на конкретное инженерное ограничение, а не произвольное архитектурное решение.
Почему появился Reflection
Введение UObject как границы решает вопрос «какие объекты особенные», но не решает следующий, не менее важный вопрос: как движковая инфраструктура — сборщик мусора, редактор, система сохранения — узнаёт, какие именно поля есть у твоего конкретного класса-наследника UObject, если сама эта инфраструктура написана один раз, задолго до того, как ты напишешь свой класс?
Обычный C++ стирает почти всю информацию о структуре типа после компиляции (это явление называется type erasure) — в скомпилированном бинарнике нет встроенного, доступного в рантайме списка «у класса ASwordActor есть поле float BaseDamage по такому-то смещению в памяти». Компилятор точно знает об этом на этапе сборки, но не оставляет это знание доступным программе во время выполнения. Для написания игрового кода этого обычно достаточно — ты и так знаешь имена своих полей на этапе написания кода. Но для написания универсального редактора, который должен уметь показать Details-панель для абсолютно любого класса, который когда-либо напишет любой программист на этом движке, включая классы, которые будут написаны через годы после релиза версии редактора — это ограничение фатально. Редактор физически не может быть написан так, чтобы «знать» заранее о каждом будущем классе каждого будущего проекта.
Решение — генерация кода поверх твоего класса, до основной компиляции
Epic решает эту проблему не изменением языка C++ (это было бы невозможно — компилятор C++ не подконтролен Epic), а дополнительным шагом сборки перед основной компиляцией: Unreal Header Tool (UHT) — отдельная программа, которая сканирует твои заголовочные .h файлы, находит специальные макросы (UCLASS, UPROPERTY, UFUNCTION) и генерирует дополнительный C++ код — файл .generated.h — который регистрирует структуру твоего класса в рантайм-системе движка: список полей, их типы, их смещения в памяти, список функций. Этот сгенерированный код и есть Reflection в Unreal — рантайм-доступная информация о структуре класса, синтезированная автоматически из твоего исходного кода до того, как обычный компилятор C++ вообще увидит файл.
Обычная сборка C++ проекта:
твой .cpp/.h файлы → компилятор (clang/MSVC) → бинарник
(информация о структуре класса
стирается после компиляции)
Сборка проекта на Unreal Engine:
твой .h файл с UCLASS()/UPROPERTY()
│
▼
Unreal Header Tool читает .h, ВИДИТ макросы,
генерирует MyClass.generated.h — код, который
регистрирует структуру класса в UClass/FProperty
│
▼
твой .cpp/.h + .generated.h → компилятор (clang/MSVC) → бинарник
(теперь бинарник содержит и твою логику,
И рантайм-описание структуры класса —
редактор и GC могут прочитать его в рантайме)
Макросы (UCLASS(), UPROPERTY(), GENERATED_BODY()) — не стилистическая прихоть, а единственный доступный Epic способ пометить нужные классы и поля прямо в обычном, валидном C++ файле так, чтобы: (а) отдельная программа (UHT) могла найти эти пометки простым текстовым разбором заголовка ещё до полноценной компиляции, и при этом (б) сам файл оставался компилируемым обычным C++ компилятором, который эти макросы просто разворачивает в пустоту или в служебный код на этапе препроцессинга. Альтернатива — отдельный файл описания интерфейса (так называемый IDL, Interface Definition Language, используемый, например, в COM или CORBA) — заставила бы разработчика поддерживать вручную два синхронизированных источника истины (C++ класс и его IDL-описание) вместо одного. Макросы позволяют держать всё описание в одном месте — прямо над полем или классом, к которому оно относится — и именно поэтому Epic выбрала этот путь, а не отдельный формат описания.
В UObject/ObjectMacros.h сами макросы обманчиво просты: UPROPERTY(...) и UFUNCTION(...) буквально разворачиваются в пустоту — #define UPROPERTY(...) без единого символа справа, и комментарий разработчиков движка прямо над ними так и говорит: «These macros wrap metadata parsed by the Unreal Header Tool, and are otherwise ignored when code containing them is compiled by the C++ compiler». UCLASS() и GENERATED_BODY() устроены хитрее — они разворачиваются не в пустоту, а в склеенный из имени файла и номера строки токен, который во время компиляции подставляет на своё место код, сгенерированный UHT в .generated.h. И деталь, которая обычно удивляет тех, кто изучал движок по старым материалам: сам UHT в актуальных версиях движка — уже не C++-программа, как была в UE4, а C#-тулчейн (в дереве исходников он лежит как Engine/Source/Programs/Shared/EpicGames.UHT, рядом с Unreal Build Tool). На описанную выше модель «сначала UHT читает макросы, потом обычный компилятор собирает результат» это не влияет — меняется только язык реализации самого инструмента, а не его роль в пайплайне.
Почему нужен Garbage Collector
Reflection решает проблему «движок знает структуру твоего класса в рантайме» — но это же самое знание оказывается ровно тем недостающим кусочком, который нужен для решения проблемы безопасного управления памятью, сформулированной в разделе 6: если система Reflection уже умеет находить все поля-указатели на другие UObject внутри твоего класса (через UPROPERTY, помечающий именно такие поля), то движок может построить полный граф ссылок между всеми живыми UObject-объектами в игре — кто на кого ссылается — и периодически обходить этот граф, начиная от заведомо «живых» корневых объектов (мир, GameInstance и подобные), помечая всё достижимое как живое, и удаляя всё недостижимое. Это и есть Garbage Collector Unreal — механизм, который стал возможен именно благодаря Reflection, а не появился независимо от него.
Упрощённая модель прохода GC (mark-and-sweep):
Корневые объекты (GameInstance, UWorld, объекты с AddToRoot)
│
▼ обход по UPROPERTY-ссылкам (известным благодаря Reflection)
│
┌────┴────┐
▼ ▼
Actor A Actor B ──▶ Component X ──▶ InventoryItem Y
│
▼
(если сюда никто больше не ссылается — объект НЕ помечен как живой)
После обхода: всё, что НЕ было помечено как достижимое,
считается мусором и освобождается при следующем проходе GC.
GC способен обойти только те ссылки, о существовании которых он знает — а знает он только о полях, зарегистрированных Reflection'ом, то есть о полях, помеченных UPROPERTY. Обычный, «сырой» C++ указатель на UObject (AActor* CachedTarget; без UPROPERTY) — для GC невидим: он не входит в граф обхода, и объект, на который он указывает, может быть удалён сборщиком, даже если этот «сырой» указатель на него всё ещё существует где-то в памяти. Ты получишь указатель, который выглядит валидным (не nullptr), но указывает на уже освобождённую память — классический use-after-free, только замаскированный под нормально работающий движок, потому что на первый взгляд код компилируется и как будто работает. Это прямая причина, почему в реальных проектах любое поле-указатель на UObject почти всегда должно быть помечено UPROPERTY — не ради видимости в редакторе, а ради физической безопасности памяти.
Начиная с UE5, когда UHT видит UPROPERTY() AActor* Target;, сгенерированный код объявляет это поле не как голый AActor*, а как TObjectPtr<AActor> (UObject/ObjectPtr.h) — обёртку вокруг «хендла» на объект (UObject/ObjectHandle.h). Комментарий Epic над TObjectPtr прямо объясняет зачем: «is meant to function as a drop-in replacement for raw pointer member properties... supports access tracking and optional lazy load behavior in editor builds... When resolved, its participation in garbage collection is identical to a raw pointer». Для повседневного кода (Target->DoSomething()) это ничего не меняет — тот же размер, тот же прямой доступ по адресу, то же участие в обходе GC. Но каждое обращение к полю становится точкой, которую движок может отследить, а не непрозрачным адресом в памяти — этим пользуются GC-барьер и (в редакторных сборках) отложенная подгрузка ещё не загруженного объекта. Мысль раздела не меняется: без UPROPERTY ссылка всё равно невидима для GC — просто у видимой ссылки под капотом чуть больше механики, чем «просто указатель».
Важно, что этот GC работает не как в managed-языках вроде C# или Java — он не сканирует непредсказуемо в любой момент, а запускается по управляемому расписанию (обычно раз в некоторое количество секунд, точный интервал настраивается), и сам обход спроектирован так, чтобы укладываться в бюджет кадра, а не останавливать игру целиком. Это прямое следствие ограничения №1 из раздела 6 — предсказуемость важнее мгновенной реакции на освобождение памяти.
Как всё это связано — единая цепочка причин
Теперь, когда каждый элемент разобран по отдельности, стоит явно проследить всю цепочку целиком — потому что именно её целостность, а не отдельные факты, и есть главный урок этой статьи.
- Проблема: писать всю инфраструктуру игры заново для каждого проекта — экономически нежизнеспособно.
- Решение 1 — Framework: движок берёт управление главным циклом на себя (инверсия управления), твой код становится набором точек расширения.
- Ограничение: движку реального времени нужен предсказуемый, быстрый язык без пауз managed-рантайма — выбран C++.
- Новая проблема от этого выбора: ручное управление памятью на C++ для тысяч взаимно ссылающихся игровых объектов, которыми управляет не только программист, — источник use-after-free.
- Решение 2 — UObject: явно выделенная категория объектов, для которых движок берёт на себя автоматическое управление и интеграцию с инфраструктурой; остальной C++ остаётся «обычным» и без этих накладных расходов.
- Новая проблема от этого выбора: универсальный редактор и другая инфраструктура должны «знать» структуру абсолютно любого будущего класса — но C++ стирает эту информацию после компиляции.
- Решение 3 — Reflection через UHT и макросы: отдельный шаг сборки генерирует рантайм-описание структуры класса из размеченного макросами исходного C++ кода.
- Возможность, которую это открывает: зная граф UPROPERTY-ссылок между объектами, движок может автоматически находить недостижимые объекты.
- Решение 4 — Garbage Collector: периодический, управляемый по расписанию обход этого графа, освобождающий недостижимые UObject.
Если ты запомнишь пять фактов по отдельности — «Unreal это фреймворк», «есть UObject», «есть Reflection», «есть GC» — ты будешь знать API. Если ты удержишь в голове именно цепочку зависимостей — ты сможешь взять любую новую, незнакомую систему движка (Gameplay Ability System, Niagara, Enhanced Input) и задать тот же самый вопрос: «какую проблему предыдущего слоя решает именно эта система, и какое новое ограничение она, в свою очередь, порождает?» — и это будет работать даже для систем, которые Epic добавит в движок уже после того, как ты закончишь читать эту энциклопедию.
Влияние на Blueprint
Blueprint — визуальный язык программирования Unreal — не отдельная, независимо построенная система поверх C++. Он напрямую паразитирует (в хорошем смысле) на инфраструктуре Reflection, описанной выше: когда UHT генерирует рантайм-описание твоего класса из UPROPERTY/UFUNCTION, ровно это же описание использует редактор Blueprint, чтобы показать твоё C++ поле как настраиваемый пин на ноде или твою C++ функцию как вызываемую ноду в графе. Blueprint не «знает» о твоём классе никаким отдельным путём — он видит ровно то же самое рантайм-описание структуры, что и GC, и редактор Details-панели, и система сериализации. Это объясняет практическое следствие, которое иначе выглядело бы как произвольное правило: если ты хочешь, чтобы дизайнер увидел твоё поле или функцию в Blueprint, единственный способ — тот же самый механизм Reflection (UPROPERTY(BlueprintReadWrite), UFUNCTION(BlueprintCallable)), а не какой-то отдельный «Blueprint API».
05Когда рассуждать в этих категориях — и когда это избыточно
| Стоит явно проговаривать цепочку «проблема → ограничение → решение» | Не стоит — рассуждение излишне |
|---|---|
| Ты проектируешь новую систему движка/плагин и не уверен, нужен ли класс UObject-наследником или обычным C++ классом | Ты используешь уже существующий, устоявшийся класс движка (например, UStaticMeshComponent) по прямому назначению |
| Ты видишь незнакомый макрос или спецификатор и хочешь понять его архитектурный смысл, а не просто скопировать из туториала | Задача полностью решается стандартной, задокументированной комбинацией существующих систем |
| Ты объясняешь новичку в команде, почему в проекте сделано именно так, а не иначе | Правки в пределах одной функции без архитектурных последствий |
Это единственная адаптация раздела «когда использовать / когда не использовать» под тему, у которой нет единственной функции с сигнатурой — здесь «использование» означает «применение этого способа рассуждения», а не «вызов конкретного API».
Как этим можно злоупотребить
У самого способа мышления тоже есть цена, и её можно платить впустую. Прогонять полную цепочку «проблема → ограничение → решение» для каждой мелкой правки — например, останавливаться и раздумывать об архитектурных причинах перед тем, как поменять число в уже существующей, давно устоявшейся настройке компонента — не инженерная строгость, а паралич анализа: время, которое можно было потратить на реальную задачу, уходит на пересборку рассуждения, которое уже давно проделала команда Epic и, скорее всего, твоя собственная команда тоже. Признак того, что рассуждение зашло не туда — если оно занимает больше времени, чем сама правка кода, а результат всё равно совпадает с очевидным, стандартным решением.
| Плюсы инверсии управления | Минусы | Цена |
|---|---|---|
| Один и тот же движок переиспользуется в тысячах разных проектов без переписывания инфраструктуры; гарантированно корректный порядок инициализации для всех проектов сразу | Разработчик не может по своему усмотрению выбрать, когда именно вызвать BeginPlay или Tick — только заполнить эти точки своим кодом |
Необходимость заранее изучить, какие точки расширения вообще существуют и в каком порядке движок их вызывает — вместо того чтобы писать произвольный сценарий с нуля |
Как это использует сама Epic
Показательный факт: даже сам редактор Unreal Engine — не отдельная программа, написанная в обход этой архитектуры, а такое же Unreal-приложение, построенное на том же самом фреймворке, что и твоя игра. Классы вроде UCharacterMovementComponent, отвечающего за движение персонажа, не имеют собственного отдельного «привилегированного» способа получать управление каждый кадр — они точно так же переопределяют TickComponent, ту же самую точку расширения, доступную и твоему собственному компоненту. Когда ты открываешь исходный код этого класса (полезный навык, отдельно разбираемый в других статьях энциклопедии) и видишь знакомую сигнатуру TickComponent, ты видишь не «магию движка», а тот же самый механизм, которым пользуешься сам — просто применённый Epic к более сложной задаче. Это прямое следствие инверсии управления: у движка нет обходного, закрытого от остальных способа получать управление в обход системы, которую он сам же и предоставляет всем разработчикам.
06Типичные ошибки
Новичок, пришедший из мира обычных программ, инстинктивно ищет «точку входа» и пытается написать в одном месте последовательный сценарий всей игры — «сначала загрузить меню, потом дождаться нажатия кнопки, потом начать уровень». Эта ошибка возникает потому, что весь предыдущий опыт программирования выстроен вокруг одной точки входа, а Unreal её физически не даёт — фреймворк ожидает, что состояние игры выражено через классы и явные переходы между ними (GameMode/GameState меняют состояние матча, UI реагирует на события), а не через ручной, императивно написанный сценарий. Опасность не в том, что код не скомпилируется — опасность в потерянном времени на поиск несуществующей точки входа вместо изучения конкретных точек жизненного цикла, которые эту роль уже выполняют.
Middle-разработчик обычно уже знает, что нужно писать UPROPERTY, но объясняет себе эту необходимость неверной причиной — «чтобы дизайнер видел поле в редакторе» — и поэтому иногда пропускает макрос для полей, которые «не нужны дизайнеру», включая указатели на другие UObject. Опасность именно в этой неверной ментальной модели: настоящая причина — видимость для Garbage Collector, а видимость в редакторе — лишь побочный эффект того же самого механизма Reflection. Пропущенный UPROPERTY на «незначительном», по мнению разработчика, указателе — прямой путь к use-after-free, который может не проявляться месяцами, пока GC не удалит объект в невезучий момент.
Senior-разработчик не спрашивает «как мне заставить это работать» — он спрашивает «какую точку расширения фреймворк уже для этого предусмотрел, и почему она устроена именно так, а не иначе». Разница между этими двумя вопросами не абстрактная — проще всего увидеть её на одной конкретной задаче.
Задача: добавить в игру инвентарь. Новичок думает предметно: «нужен инвентарь — значит, нужен класс UInventoryComponent с массивом предметов внутри», после чего идёт искать готовый туториал именно под эту задачу и копирует найденное решение. Проблема не в том, что решение обязательно окажется неверным, — проблема в том, что новичок не понимает, почему оно устроено именно так, и поэтому не сможет самостоятельно адаптировать его, когда требования чуть изменятся (например, инвентарь должен пережить смерть персонажа).
Senior не ищет туториал по инвентарю — он проходит по цепочке уже известных ему причин, ровно тем же способом, что разобран в этой статье:
- Данные инвентаря должны пережить смерть персонажа, а сам
Pawnпри смерти уничтожается — значит, инвентарь физически не может храниться в нём. Из статьи про Gameplay Framework уже известно, что именно для такого случая существуетPlayerState— он специально спроектирован жить дольше конкретного тела персонажа. - Новая проблема: как хранить сам список предметов внутри
PlayerState? Из статьи про контейнеры уже известно, чтоTArray— обычный выбор там, где важен порядок элементов, в отличие отTMap, оптимизированного под поиск по ключу, а не под порядок следования. - Новая проблема: как предмет должен ссылаться на свои неизменные характеристики (урон, вес, иконка) отдельно от изменяемого состояния (текущее количество, прочность)? Из статьи про Data Driven-подход уже известно, что неизменные данные предмета — задача для отдельного
DataAsset, а изменяемое состояние остаётся обычным полем в структуре самого слота инвентаря, не дублируя данные ассета. - Новая проблема: как этот список должен синхронизироваться по сети, не пересылая целиком весь инвентарь при изменении одного-единственного предмета? Из статьи про репликацию уже известно общее правило — реплицировать нужно минимум, достаточный для восстановления состояния, а не весь объект целиком; для массивов у движка есть отдельный, специально спроектированный под эту задачу механизм эффективной репликации.
Senior не запомнил «как делать инвентарь» как отдельный факт — он собрал решение из четырёх уже понятых им ранее причинно-следственных цепочек, каждая из которых когда-то тоже была ответом на вопрос «какую проблему это решает». Столкнувшись с любой новой, незнакомой системой движка (Gameplay Ability System, Niagara, Enhanced Input), он ищет ровно этот же паттерн: какая проблема предыдущего слоя решается этой системой, и какое новое ограничение эта система, в свою очередь, порождает — вместо того чтобы запоминать API как список независимых фактов.
07Практика
SpawnActor / BeginPlay: кто кого вызывает в каждом из двух случаев, и почему это разные направления вызова?Показать ожидаемый ход рассуждения
SpawnActor — ты вызываешь функцию движка: управление находится у твоего кода, движок здесь ведёт себя как обычная библиотека. BeginPlay — движок вызывает твою функцию в заранее определённой им точке жизненного цикла: управление находится у фреймворка, инверсия управления в действии. Важно уметь для любой функции движка определить, в какую из этих двух категорий она попадает — это база для понимания архитектуры буквально любой системы Unreal.
Показать пример ожидаемого хода рассуждения (не готовый ответ)
Ожидаемая форма ответа — не факт, а рассуждение по образцу этой статьи: «раньше ввод, вероятно, обрабатывался напрямую проверкой нажатой клавиши в коде — это создаёт проблему Х (например, невозможность переназначить клавиши без правки кода). Новая система, вероятно, вводит промежуточный уровень абстракции Y, что, в свою очередь, добавляет ограничение Z (дополнительная настройка вместо одной строчки кода)». Сам факт того, что ты пытаешься сформулировать гипотезу ДО чтения темы, а не после — и есть то самое «инженерное мышление», которому учит эта статья.
08Частые вопросы
Потому что Reflection и GC в managed-языках спроектированы для общего случая — они не дают контроля над тем, КОГДА именно происходит пауза сборщика мусора, а движку реального времени критична предсказуемость каждого отдельного кадра (раздел 6). Unreal решает эту задачу иначе: берёт язык (C++), который в принципе не навязывает никакого GC, и добавляет свой собственный, контролируемый по расписанию сборщик только для одной чётко выделенной категории объектов (UObject), оставляя всю остальную, низкоуровневую часть движка (математику, контейнеры, рендер-код) полностью без этих накладных расходов. Managed-язык такой избирательности не позволяет — в нём под управлением GC находится вообще всё, что ты создаёшь.
Нет, и это осознанное архитектурное решение, а не недосмотр (раздел 7). Простые структуры данных без необходимости в GC, редакторе или сериализации (математический вектор, вспомогательная утилитная структура, используемая только внутри одной функции) чаще всего остаются обычными C++ классами/структурами — они не платят цену Reflection и GC, потому что она им не нужна. Решение всегда принимается вопросом «нужна ли этому классу интеграция с движковой инфраструктурой», а не по умолчанию.
Нет — ты полностью контролируешь порядок выполнения ВНУТРИ своих собственных функций (что происходит внутри твоего BeginPlay, ты решаешь сам, строчка за строчкой). Ты не контролируешь только то, КОГДА фреймворк вызовет эти функции относительно других частей движка и других твоих же классов — а для этого у фреймворка есть явно документированные гарантии порядка (например, «BeginPlay гарантированно вызывается после того, как все компоненты актора зарегистрированы»), на которые можно и нужно полагаться, вместо того чтобы пытаться обойти систему искусственными задержками.
Нет — обращение к значению поля вроде Health = 100.f; в скомпилированном коде остаётся обычной, прямой операцией доступа к памяти по смещению, точно такой же быстрой, как для любого обычного C++ поля. Накладные расходы Reflection проявляются не при обычном чтении/записи поля из C++ кода, а при операциях, которые СПЕЦИАЛЬНО используют рантайм-описание структуры — переборе всех полей класса по имени (например, при сериализации в файл сохранения), построении Details-панели редактора, поиске функции по имени для вызова из Blueprint. Обычная повседневная игровая логика на C++ эту цену не платит.
Нет, и архитектура, разобранная в этой статье, объясняет почему: Blueprint технически не может существовать без C++-слоя, который он визуализирует (раздел 11) — сама Reflection-информация, которую видит Blueprint-редактор, генерируется из C++ классов, размеченных UPROPERTY/UFUNCTION. Практика, устоявшаяся в индустрии и в самой Epic (см. проект Lyra), — тяжёлая, часто выполняемая логика и базовые системы пишутся на C++ ради производительности и контроля, а Blueprint используется поверх готовых C++ классов для быстрой настройки конкретных значений, связывания событий и итерации дизайнером без участия программиста на каждый чих.
09Что читатель должен начать замечать
После этой статьи незнакомый макрос или спецификатор в чужом коде перестаёт быть поводом просто скопировать его из туториала — вместо этого возникает рефлекс спросить, какую именно инфраструктуру движка (GC, редактор, Blueprint, сериализацию) он подключает, и заплачена ли эта цена не зря.
Указатель на UObject без UPROPERTY в чужом или своём коде теперь читается не как мелкая недоработка, а как потенциальный use-after-free — потому что после раздела про Garbage Collector ясно: невидимость для GC не абстрактный риск, а прямое следствие того, как устроен обход графа ссылок.
А при столкновении с любой новой, ещё не изученной системой движка (Niagara, Mass Entity, Chaos) естественным первым вопросом становится не «как этим пользоваться», а «какую проблему предыдущего слоя архитектуры она решает, и какое новое ограничение порождает» — тот самый способ мышления, который эта статья описывает как главный урок, а не набор из пяти фактов.
10Итоги
- Unreal Engine — фреймворк, а не библиотека: он владеет главным циклом программы, и твой код — это набор точек расширения, вызываемых движком в заранее определённых местах (инверсия управления), а не набор функций, которые ты вызываешь сам и в том порядке, который выбираешь сам.
- Инверсия управления — не эстетика, а единственный способ сделать один и тот же движок переиспользуемым между проектами с разной игровой логикой: движок гарантирует один корректный порядок инициализации для любого проекта, а твоя ответственность сужается до заполнения уже безопасных точек расширения.
- C++ выбран как язык реализации из-за жёсткого бюджета кадра: managed-языки с непредсказуемым GC вносят паузы, которые движок реального времени не может себе позволить, — но и чистый C++ с ручным
new/deleteне масштабируется на тысячи взаимно ссылающихся объектов, временем жизни которых управляет не только программист, а ещё редактор, загрузка уровней и сеть. - UObject — не «базовый класс для всего», а осознанно проведённая граница: по одну сторону — объекты, за которых движковая инфраструктура (GC, редактор, сериализация, сеть) берёт на себя ответственность и накладные расходы; по другую — обычный C++, который эту цену не платит, потому что она ему не нужна.
- Reflection существует потому, что универсальной инфраструктуре движка (редактору, сериализации) нужно «знать» структуру абсолютно любого, в том числе ещё не написанного класса, а обычный C++ стирает эту информацию после компиляции — Unreal Header Tool восстанавливает её отдельным шагом сборки, генерируя рантайм-описание класса из UCLASS/UPROPERTY/UFUNCTION.
- Garbage Collector стал возможен именно благодаря Reflection, а не появился независимо от неё: зная через UPROPERTY, какие поля — ссылки на другие UObject, движок способен построить граф достижимости и освобождать недостижимые объекты по управляемому, предсказуемому расписанию, а не в произвольный момент.
- Blueprint не имеет собственного, отдельного от C++ источника знаний о твоих классах — он читает ровно то же Reflection-описание, что и GC и редактор Details-панели, поэтому единственный способ сделать поле или функцию доступной дизайнеру — тот же UPROPERTY/UFUNCTION, что использует остальная инфраструктура.
- Это не пять изолированных фактов, а одна цепочка причин и следствий, и запоминать стоит именно цепочку: «какую проблему предыдущего слоя решает эта система, и какое новое ограничение она в свою очередь порождает» — вопрос, применимый к любой ещё не изученной системе движка, включая те, что появятся в Unreal уже после того, как ты пройдёшь эту энциклопедию.
Когда встречаешь незнакомую систему движка, не спрашивай «как этим пользоваться» — спрашивай «какую проблему предыдущего слоя архитектуры она решает, и какую новую проблему создаёт этим решением». Framework решил проблему инфраструктуры ценой инверсии управления. UObject решил проблему use-after-free ценой границы «дорогой» и «дешёвой» частей C++. Reflection решил проблему type erasure ценой макросов и лишнего шага сборки. GC решил проблему безопасности графа ссылок ценой периодических накладных расходов. В архитектуре Unreal нет магии — есть только ещё не найденная тобой цена, которую платит предыдущее решение.
Материал подготовлен для UE C++ Academy. Оригинал статьи — uecppacademy.com. Если материал помог вам разобраться в Unreal Engine — вы можете поддержать развитие проекта.
© 2026 UE C++ Academy. Все права защищены. По вопросам авторских прав или технических проблем — support@uecppacademy.com