главная / блог / движок
Опубликовано: 24 мая 2026 г. 9 мин чтения движок
Blueprint или C++ в Unreal: реальная цена в миллисекундах

Blueprint или C++ в Unreal: реальная цена в миллисекундах

Виртуальная машина Blueprint незаметна в большинстве сцен, но при десятках тысяч узлов за тик она стоила нам 7 мс. Разбираю гибридное разделение в цифрах.

Когда за один тик выполняется 41 000 узлов

В проекте tower defence в конце 2023 года время кадра росло с 11,2 мс до 19,4 мс, как только начиналась четырнадцатая волна. В редакторе всё выглядело приемлемо, а в упакованной сборке Development разрыв был очевиден. Верхней строкой в Unreal Insights оказался BlueprintTime, съедавший 7,1 мс в одиночку.

Виновником был Event Tick внутри BP_TowerBase. Каждая башня обходила все 220 противников через ForEachLoop, считала квадрат расстояния и выбирала ближайшего. Двадцать четыре башни на 220 противников, плюс несколько операций на узел, — это примерно 41 000 выполнений узлов за кадр.

Проблема была не в том, что «Blueprint медленный». Проблема была в том, что поиск сложности O(n·m) выполнялся в виртуальной машине каждый кадр. Перенос этого кода на C++ действительно снижает стоимость, но настоящая ошибка была алгоритмической, и в споре «Blueprint или C++ в Unreal» эти две вещи постоянно путают.

Тогда нас было трое, и профилированием стороны Blueprint не занимался никто. По мере роста времени кадра мы сначала смотрели на draw call, потом на настройки теней, потом на разрешение текстур. Потеряв два дня, мы просто открыли Insights и прочитали нужную строку. Всё остальное в этой статье — правила, которые я записал, чтобы не повторять те два дня.

Где производительность Blueprint становится измеримой

Чтобы получить число, я сделал простой тест: вызвал пустую функцию 100 000 раз. На стороне C++ суммарно вышло 0,4 мс, та же самая функция в Blueprint заняла 6,8 мс. Это примерно 68 нс накладных расходов на вызов.

68 нс выглядят как ничто, и чаще всего так и есть. Для открытия двери на 200 узлов или логики экрана инвентаря стоимость остаётся ниже уровня шума: примерно до 2 000 узлов за кадр суммарный эффект ни в одном из моих замеров не превысил 0,15 мс.

Момент, когда это перестаёт быть шумом, вполне понятен: десятки тысяч узлов за кадр. Грубый порог, которым я пользуюсь, такой — если Blueprint выполняется каждый кадр и содержит цикл, эта логика уже кандидат на C++. Для потоков, которые запускаются разовыми событиями, накладные расходы VM обсуждать бессмысленно.

Есть и ловушка измерений. В PIE вызовы Blueprint выглядят дороже, чем есть на самом деле, из-за отладочной инструментации, поэтому решать по цифре из редактора — вводить себя в заблуждение. Я не переношу ничего на C++, пока не посмотрю упакованную сборку Development через stat game и Insights.

Гибридный процесс: ядро на C++, настройка в Blueprint

Выбор цели, расчёт урона, отслеживание cooldown и машина состояний переехали в AOFKTowerBase на C++. Поиск цели больше не выполняется каждый кадр: он идёт по таймеру в 0,2 секунды по пространственной сетке. В Blueprint остались тайминг эффектов, триггеры звука, обратная связь интерфейса и числа, которые дизайнер правит постоянно.

Время кадра снизилось с 19,4 мс до 11,8 мс, а BlueprintTime — с 7,1 мс до 0,9 мс. Не всё это заслуга VM: около 5 мс приходится на смену алгоритма и 2,6 мс — на переход к нативному коду. Без такого разделения фраза «C++ ускорил всё на 40 %» была бы нечестной.

Два варианта мы отбросили. Полностью уйти в C++ было технически быстрее всего, но дизайнеру ради одного значения урона приходилось ждать 90 секунд компиляции и перезапуск редактора; для человека, который делает 40 проб в день, это неприемлемо. Оставить всё в Blueprint и просто отключить тик не дало ничего, потому что цикл, породивший стоимость, никуда не девался.

Вопрос, который я задаю, когда провожу границу, простой: требует ли изменение этого значения компиляции? Если да, и дизайнер меняет его несколько раз в неделю, это значение должно жить в Blueprint или в data asset. Таблицу баланса башен мы храним как UDataAsset: C++ её читает, дизайнер правит в редакторе, и никто не ждёт сборку.

AOFKCharacter.h
#pragma once #include "CoreMinimal.h" #include "GameFramework/Character.h" #include "AOFKCharacter.generated.h" UCLASS() class OFKGAME_API AOFKCharacter : public ACharacter { GENERATED_BODY() public: // Designer-tunable value, read-only in Blueprint so the rule stays in C++. UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat", meta = (ClampMin = "0.05", UIMax = "2.0")) float DashCooldown = 0.35f; UFUNCTION(BlueprintCallable, Category = "Combat") bool CanDash() const; protected: // C++ decides when it fires; Blueprint decides how it looks and sounds. UFUNCTION(BlueprintImplementableEvent, Category = "Combat") void OnDashStarted(float Cooldown); private: float LastDashTime = -1000.f; };
cppAOFKCharacter.h

Как открыть правильную поверхность через UPROPERTY

Качество гибридной схемы зависит от того, что именно C++ открывает наружу для Blueprint. Настраиваемые числа выходят как UPROPERTY(EditAnywhere, BlueprintReadOnly): дизайнер меняет значение в редакторе, но не может писать в него во время выполнения. Добавленный meta = (ClampMin, UIMax) убирает половину ошибок вида «кто-то случайно ввёл 0» ещё до их появления.

Со стороны функций разница между тремя спецификаторами важна. BlueprintCallable — это Blueprint, задающий вопрос C++; BlueprintImplementableEvent — это C++, сообщающий Blueprint «вот это только что произошло, а как оно выглядит, решай сам»; BlueprintNativeEvent даёт реализацию по умолчанию в C++, которую Blueprint при необходимости переопределяет. Правило короткое: C++ решает когда, Blueprint решает как это выглядит.

Самая частая ошибка, которую я вижу, — ставить BlueprintReadWrite на каждое поле. Как только доступ открыт, дизайнеры начинают писать в переменные состояния, и инварианты, которые вы охраняете в C++, тихо ломаются; у нас это всплыло как ушедший в минус счётчик патронов. Вторая ловушка — переименование: смена имени UPROPERTY молча рвёт ссылки в Blueprint, поэтому мы не переименовываем поле без записи в Core Redirects.

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

Что изменилось после удаления nativization

Во времена 4.26 некоторые команды полагались на Blueprint nativization, и мы тоже какое-то время её пробовали. Измеренный выигрыш в тяжёлых сценах составлял около 1,6 мс; взамен упаковка стала длиннее на 18 минут, и мы поймали три бага, которые проявлялись только в нативизированных сборках. В UE5 эту опцию убрали полностью.

Практическое следствие: запасного выхода на поздней стадии, где компилятор всё спасёт, больше нет. Держать горячий путь в C++ — уже не шаг оптимизации, а архитектурное решение, принимаемое в начале проекта. Отношение к UE5 C++ как к заплатке, которую прикрутят позже, и подводит команды в середине продакшена.

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

Вместо nativization мы поставили измерение. Перед каждым релизом мы снимаем трассу Insights с упакованной сборки, и когда BlueprintTime переваливает за 1 мс, ищем виновный Blueprint. Проверка занимает пятнадцать минут. За прошедший год она трижды поймала серьёзную регрессию до выхода.

Конфликты слияния, когда команда растёт

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

Мы настроили обязательный checkout и эксклюзивные блокировки в Perforce. Конфликты прекратились, а на их место пришло ожидание: пока Blueprint заблокирован, второй человек либо ждёт, либо переключается на другую задачу. У C++ такой проблемы нет — текст сливается построчно и его действительно можно прочитать в pull request. Ревью Blueprint на практике сводится к отправке скриншотов.

Сегодня в разработке на Unreal Engine я применяю четыре правила. Никакая логика, выполняемая каждый кадр, в Blueprint не остаётся, и тик по умолчанию выключен в каждом создаваемом нами Blueprint. Если Blueprint перевалил за 150 узлов, часть его выносится в C++. И если двум людям нужно править один и тот же Blueprint в пределах одного спринта, эта логика уже принадлежит C++.

Эти правила касаются скорости команды не меньше, чем производительности. Используйте Blueprint для быстрых итераций дизайнера, а C++ — как хребет системы, и проводите границу до первой строки кода. Переносить потом всегда дороже: наш счёт составил три недели рефакторинга.

← Все статьи