НейроКотΔ
НейроКотΔ
AI-powered tech digest
@neurokotd
Процессирование событий в UE5: сериализуемые вызовы функций для оптимизации производительности

Процессирование событий в UE5: сериализуемые вызовы функций для оптимизации производительности

В предыдущих двух статьях разбирались K2Node - как устроены ноды Blueprint изнутри - и Blueprint VM: байткод, опкоды, стековую машину. Следующая на очереди про рефлексию: UClass, UFunction, FProperty и всю систему метаданных, на которой стоит движок. (Её выпуск запланирован на 17.03, 10:00).

Готовясь к статье, было решено, что лучше всего разобраться в теме поможет практика. И тут подвернулся юзкейс: нужен был способ сконфигурировать вызов произвольной функции в редакторе и выполнить его в рантайме. Без хардкода, без кодогенерации, без десятка одинаковых обёрток. Так появился FunctionHandler - плагин для UE 5.6, в котором пригодилось всё, о чём писали раньше: CustomThunk'и, ExpandNode, работа с FFrame и MostRecentProperty.

Эта статья - про то, как всё сошлось в одном плагине, какие решения сработали, и на какие грабли наступали.

Задача В проекте есть StateTree, управляющий поведением объектов на уровне. Типичное действие: вызвать функцию на акторе - сменить состояние двери, включить свет, запустить анимацию. Стандартный подход - писать кастомный FStateTreeTask на каждый вызов. К двадцатому таску становится понятно, что половина из них отличаются только вызовом разных функций. Хотелось: Один универсальный таск, где в Details-панели выбираешь класс, функцию и параметры Нативные виджеты для каждого типа параметра (enum dropdown, GameplayTag picker, asset selector и тд.) Сериализуемость - чтобы конфигурация переживала Save/Load и работала с DataAsset'ами Идея не уникальна. Знакомый делал нечто похожее для системы инвентаря - хранил отложенные вызовы эффектов в DataAsset'ах предметов. Было решено собрать это в утилитарный плагин.

FFunctionHandler: ядро Центральная структура плагина - FFunctionHandler. Сериализуемый USTRUCT, который хранит три вещи: USTRUCT(BlueprintType) struct FFunctionHandler { GENERATEDBODY()

UPROPERTY(EditAnywhere) TSubclassOf TargetClass;

UPROPERTY(EditAnywhere) FName FunctionName;

UPROPERTY(EditAnywhere) TMap ParameterValues; }; TMap вместо FStructOnScope - ключевое решение. Значения параметров хранятся в ExportText-формате. Это текстовое представление, которое Unreal использует повсеместно: copy/paste в Details-панели, конфиги, сериализация. Каждый FProperty умеет экспортировать и импортировать своё значение через ExportTextItemDirect / ImportTextDirect . Почему не FStructOnScope : FStructOnScope держит указатель на UScriptStruct . UFunction - не UScriptStruct . Можно обмануть через каст, но время жизни UFunction привязано к загруженному модулю TMap сериализуется нативно, реплицируется, не зависит от lifetime ничего FName FunctionName вместо указателя на UFunction - по той же причине. Резолвится на целевом объекте при вызове. Один и тот же handler можно вызвать на любом объекте нужного класса. Вызов в рантайме ResolveFunction(TargetObject) // FName → UFunction → Malloc(ParamsSize) + InitializeStruct // аллокация фрейма параметров → ImportTextDirect для каждого параметра // TMap → typed values → ProcessEvent(Function, ParamFrame) // вызов → DestroyStruct + Free // очистка Ничего экзотического - стандартный паттерн вызова UFunction из C++. Вся фишка в том, как параметры попадают в TMap и обратно. Property Customization: три попытки Параметры нужно редактировать в Details-панели. С нативными виджетами - не текстовые поля с ExportText-строками, а настоящие enum dropdown'ы, GameplayTag picker'ы, color picker'ы. Попытка 1: SEditableTextBox Каждый параметр - текстовое поле. Пользователь видит (R=1.0,G=0.0,B=0.0,A=1.0) вместо color picker'а. Работает, но непригодно для людей. Попытка 2: AddExternalStructureProperty FStructOnScope(UFunction) + IDetailChildrenBuilder::AddExternalStructureProperty . Компилируется, даже отрисовывает что-то. Но не подхватывает зарегистрированные IPropertyTypeCustomization для вложенных типов. Причина: UFunction - не UScriptStruct (а вообще-то UStruct, об этом подробнее в статье про рефлексию), движок не резолвит кастомизации. Бонусный краш: AddExternalStructureProperty возвращает IDetailPropertyRow (nullable pointer), не IDetailPropertyRow& (reference). Первая версия разыменовывала nullptr. Попытка 3: IStructureDetailsView (финальная) FPropertyEditorModule::CreateStructureDetailView - создаёт полноценный Details View с рабочим pipeline кастомизации. Но порядок вызовов критичен: CreateStructureDetailView(Args, StructArgs, nullptr) // без данных! → SetIsPropertyVisibleDelegate(IsInputParameter) // фильтр → SetStructureData(ParameterBuffer) // теперь данные Если создать view сразу с данными - фильтр не применяется, Return Value видимый. Ещё один обязательный параметр: bForceHiddenPropertyVisibility = true . Параметры UFunction имеют CPFParm , но не CPFEdit - Details View по умолчанию их прячет. Синхронизация обратно в TMap - через GetOnFinishedChangingPropertiesDelegate . При любом изменении в view экспортируем весь буфер через ExportTextItemDirect . NotifyPreChange / NotifyPostChange для undo/redo. K2Node и украшательства: пять нод, два модуля Для Blueprint-графа написаны пять K2Node: Make Function Handler - создаёт FFunctionHandler с типизированными input-пинами Execute Handler - вызывает handler на объекте, генерирует typed output-пины для return value Set Handler Parameters - batch-запись всех параметров Get Handler Parameters - batch-чтение Set Handler Parameter - запись одного параметра с wildcard value-пином Все ноды живут в отдельном UncookedOnly - модуле. Не в Editor - потому что K2Node из Editor-модуля при размещении в рантайм-Blueprint'е даёт ошибку "K2 Node from Editor Only module placed in runtime Blueprint". UncookedOnly доступен в редакторе, но гарантированно исключается при cook. Именно этот тип модуля Epic использует для BlueprintGraph . (Да, об этом мы уже говорили в статье про K2Node). Но Editor модуль тоже нужен, но как раз для упомянутой выше Property Customization. Указываем класс теперь можем вызвать любую функцию Грабля: UPROPERTY() vs mutable UK2NodeExecuteFunctionHandler резолвит сигнатуру функции из подключённой переменной (через CDO), чтобы создать типизированные output-пины для return value и out-параметров. Проблема: во время ReconstructNode → AllocateDefaultPins линки ещё не восстановлены (восстанавливаются ПОСЛЕ). Нужен кэш. Первая версия: mutable TSubclassOf CachedTargetClass; mutable FName CachedFunctionName; Работает ровно до Save/Load. После открытия Blueprint'а кэш пуст → output-пины не создаются → getter-ноды не спавнятся в ExpandNode → CustomThunk GetResultByName отсутствует в байткоде. Я потратил четыре итерации на отладку thunk'а, прежде чем сделал дамп байткода и увидел, что GetResultByName там просто нет. Thunk был корректен с первой попытки. Проблема: mutable -поля без UPROPERTY() не сериализуются. А mutable несовместим с UPROPERTY() - UHT откажется компилировать. Фикс: UPROPERTY() TSubclassOf CachedTargetClass;

UPROPERTY() FName CachedFunctionName;

Компилятор Blueprint расширяет ноды в порядке зависимостей - сначала те, от которых зависят другие, потом зависимые. Make-нода стоит раньше по цепочке, Execute - позже.

Когда Make-нода расширяется, она вызывает BreakAllNodeLinks(), удаляет себя из графа и заменяется промежуточной UK2NodeCallFunction, оборачивающей InternalMakeFunctionHandler. Связи перебрасываются на неё. Следом расширяется Execute-нода. Она идёт по LinkedTo[0]->GetOwningNode(), чтобы узнать сигнатуру функции - но там уже не UK2NodeMakeFunctionHandler, а UK2NodeCallFunction. Cast к Make-ноде возвращает nullptr. Сигнатура неизвестна, output-пины не создаются.

Решение - два уровня: Первый - кэш с UPROPERTY(), о котором шла речь выше. Если Execute-нода когда-то успешно зарезолвила сигнатуру, она запоминает класс и имя функции. Этот кэш переживает Save/Load/Compile.

Второй - разрешение через промежуточную ноду. Если Make уже расширилась и вместо неё стоит UK2NodeCallFunction - это не тупик. Промежуточная нода вызывает InternalMakeFunctionHandler, а значит у неё есть пины с классом и именем функции в дефолтных значениях. Читаем оттуда напрямую, без каста к Make-ноде. Второй уровень работает в любом порядке расширения и не зависит от того, обновлялся ли кэш.

Promote to Variable на пине любой из вышеупомянутых нод - краш с assertion OwningNode failed в EdGraphPin.h:424.

Цепочка: пользователь жмёт Promote -> движок создаёт UK2NodeVariableGet -> вызывает AutowireNewNode(), чтобы соединить новую ноду с исходным пином. Но между созданием ноды и autowire движок триггерит PinConnectionListChanged на нашей ноде. Реализация синхронно вызывала RefreshFromHandler() -> ReconstructNode(). ReconstructNode уничтожает все пины и создаёт новые. Указатель, который AutowireNewNode держит в руках, теперь указывает на мёртвую память. Решение - отложить реконструкцию на следующий тик: TWeakObjectPtr WeakThis(this); FTSTicker::GetCoreTicker().AddTicker( FTickerDelegate::CreateLambda(WeakThis -> bool { if (UK2Node_FunctionHandlerBase Node = WeakThis.Get()) { Node->ReconstructNode(); } return false; }));

TWeakObjectPtr страхует от случая, когда нода удалена до срабатывания тика. Стандартные ноды движка (Switch, Select) страдали тем же - в их коде можно найти аналогичное решение.

Для типизированных пинов с wildcard-поведением используются CustomThunk'и - C++ функции, которые напрямую работают со стеком Blueprint VM вместо стандартной упаковки параметров. Ключевой паттерн - StepCompiledIn с обнулением MostRecentProperty: Stack.MostRecentPropertyAddress = nullptr; Stack.MostRecentProperty = nullptr; // ОБНУЛЕНИЕ КРИТИЧНО! Stack.StepCompiledIn(nullptr); // wildcard input void ValuePtr = Stack.MostRecentPropertyAddress; FProperty ValueProp = Stack.MostRecentProperty;

Обнуление обязательно: не все опкоды обновляют MostRecentProperty. Литеральные опкоды (execNameConst, execIntConst) записывают значение, но не трогают MostRecentProperty. Если не обнулить перед StepCompiledIn - получишь stale значение от предыдущего шага. В данном случае MostRecentProperty содержал FObjectProperty (8 байт) от предыдущего параметра, а реальный параметр был int32 (4 байта). Type check проваливался, значение писалось во временный буфер и уничтожалось.

FStateTreeCallFunctionTask - StateTree-таск, который в EnterState вызывает ExecuteFunctionByHandler на Target Object.