Каждый раз, когда вы соединяете ноды в Blueprint и нажимаете Play, Unreal Engine запускает маленький процессор. У него свои инструкции, свой стек, своя защита от бесконечных циклов. Он написан в ~4000 строках C++ и живёт в одном файле. Через него проходит каждый Event Tick, каждый Event BeginPlay, каждый вызов Blueprint-функции.
Этот процессор - Blueprint VM (Virtual Machine). И сегодня мы разберём его по винтикам.

Это продолжение статьи про K2Node. В прошлый раз мы дошли до момента, где ExpandNode() передаёт промежуточные ноды компилятору. Сегодня - всё, что происходит до этого и дальше: как граф становится байткодом, и как этот байткод исполняется.
Каждая секция самодостаточна. Если вам хватит общей картины - можно остановиться после второй. Если хотите видеть конкретные строки кода - дочитайте до конца.
1. Зачем вообще нужна виртуальная машина
Как процессор исполняет код
Процессор - конечный автомат. Он делает одну вещь: берёт следующую инструкцию из памяти, исполняет её, берёт следующую. Миллиарды раз в секунду.
Инструкции - это числа. 0x55 на x86 означает push rbp. 0xC3 - ret. Когда вы компилируете C++ через MSVC или Clang, компилятор превращает текст в последовательность таких чисел. Результат (.exe, .dll) - машинный код, который процессор исполняет напрямую.
Проблема: Blueprint - не C++
Blueprint - визуальный граф. Скомпилировать его в машинный код x86 напрямую нельзя, и вот почему:
Скорость итерации. Изменили Blueprint, нажали Compile - перекомпиляция за доли секунды, все экземпляры в сцене обновились. Для C++ это Live Coding с пересборкой DLL - секунды ожидания, и не всегда стабильно. Нативная компиляция Blueprint убила бы эту скорость.
Безопасность. Если Blueprint-нода обращается к
None, мы хотим получить "Accessed None" в логе, а не вылет приложения. Нативный код не прощает access violation.Отладка. Breakpoints прямо в графе, пошаговое исполнение, инспекция значений на пинах - всё это требует контроля над каждым шагом выполнения.
Решение - виртуальная машина. Вместо машинного кода генерируем свой набор инструкций - байткод - и пишем на C++ программу, которая его интерпретирует.
Что такое виртуальная машина
VM - программа, которая притворяется процессором. У неё свой instruction pointer, свои инструкции, свой стек. Но вместо кремния и транзисторов - цикл на C++:
while (running) { uint8 opcode = *ip++; // прочитать опкод handlers[opcode](state); // выполнить }
Это dispatch loop - цикл диспетчеризации. Каждый опкод - число от 0 до 255. Каждому числу соответствует функция-обработчик. Массив handlers[] - таблица dispatch.
Почему именно 255, а не, скажем, 1024? Один опкод - один байт. Dispatch сводится к array[byte] - одна операция, без ветвления, без хеширования. Если бы опкодов было 1024 - читать два байта, массив обработчиков в 4 раза больше (больше промахов кэша), а выигрыша ноль: UE использует ~80 опкодов из 255. С запасом.
Именно так устроена Blueprint VM. И точно так же устроены JVM (Java), CLR (.NET), CPython, Lua. Паттерн один - детали у каждого свои.
Историческая сноска. Blueprint VM - потомок UnrealScript из Unreal Engine 1 (1998). Тим Суини написал полноценный скриптовый язык с компилятором и VM. Blueprint унаследовал архитектуру VM, но заменил текстовый синтаксис визуальным графом. Стековая машина, dispatch по таблице опкодов, FFrame как стековый фрейм - всё это пришло оттуда.
Стековая vs регистровая
Прежде чем лезть в код UE - одно важное архитектурное решение.
Реальный процессор - регистровый. У него есть фиксированный набор ячеек памяти - регистров (можно представить как именованные переменные внутри самого процессора: rax, rbx, rcx...). Каждая инструкция явно указывает, откуда читать и куда писать: add rax, rbx означает "возьми значения из rax и rbx, сложи их, результат положи обратно в rax".
Есть альтернатива - стековая VM. Никаких именованных регистров. Все операции работают с вершиной стека:
PUSH 3 // стек: [3] PUSH 4 // стек: [3, 4] ADD // стек: [7] (сняли 3 и 4, положили 3+4)
JVM, CPython, .NET CLR - стековые. Lua 5, Dalvik (Android) - регистровые.
Blueprint VM - стековая, но с нюансом. В классической стековой VM (JVM) есть явный стек операндов. В Blueprint VM стек операндов неявный - это C++ call stack. Когда обработчик опкода хочет получить аргумент, он вызывает Step(), который рекурсивно вычисляет подвыражение. Результат передаётся через указатель RESULT_PARAM.
Почему стековая, а не регистровая?
Простота генерации. Стековый байткод генерировать проще - не нужен register allocation (один из самых сложных этапов в компиляторах). Для визуального языка, где скорость компиляции критична, это существенно.
Компактность. Не нужно кодировать номера регистров в каждой инструкции - меньше байткод, меньше расход памяти.
Рекурсивная природа. Blueprint-граф - дерево выражений. Стековая VM естественно выражает деревья через вложенные вызовы. Регистровая потребовала бы разложение в линейную последовательность.
Производительность достаточна. Тяжёлые вычисления (рендер, физика, навигация) и так живут в нативном C++. Blueprint вызывает их через
EX_CallMath- а это прямой вызов, overhead минимален. Разница между стековой и регистровой VM заметна только в чистых вычислениях, которые Blueprint и не предназначен делать.
Если сравнивать с другими: JVM - ~200 опкодов, CPython - ~120, Lua 5.4 - ~80. Blueprint VM со своими ~80 - в той же лиге, но с уникальной спецификой: она заточена под игровой цикл и сетевую репликацию.
2. Байткод - язык Blueprint VM
Что лежит внутри UFunction::Script
Каждая скомпилированная Blueprint-функция - объект UFunction. У него есть поле Script - обычный TArray<uint8>, массив байт. Это и есть байткод.
Script = [0x46, ptr, ..., 0x0F, ptr, ..., 0x16, 0x04, 0x0B, 0x53] ^^^^ ^^^^ ^^^^ ^^^^ ^^^^ ^^^^ LocalFinal Let End Ret Nop EndOf Function Parms urn Script
Внутри - не только опкоды. Между ними вшиты данные: указатели на UObject и FProperty, числа, строки, FName. VM читает всё последовательно, продвигая instruction pointer байт за байтом.
EExprToken: система команд
Все опкоды определены в enum EExprToken (файл Script.h, строка 189). Разберём по группам - не все 80, а те, которые важны для понимания.
Чтение переменных: откуда брать данные
VM должна уметь обращаться к разным "местам" в памяти. Для каждого "места" - свой опкод:
Опкод | Что читает |
|---|---|
| Локальная переменная функции (из |
| Свойство объекта ( |
| Значение из CDO (Class Default Object) |
| Out-параметр функции |
После опкода в байткоде идёт указатель на FProperty. VM читает его, чтобы знать смещение в памяти и тип свойства.
Пример: EX_InstanceVariable (файл ScriptCore.cpp, строка 2276):
DEFINE_FUNCTION(UObject::execInstanceVariable) { FProperty* VarProperty = Stack.ReadPropertyUnchecked(); // Вычисляем адрес: база объекта + смещение свойства Stack.MostRecentPropertyAddress = VarProperty->ContainerPtrToValuePtr<uint8>(P_THIS); // Если кто-то ждёт значение - копируем if (RESULT_PARAM) VarProperty->CopyCompleteValueToScriptVM(RESULT_PARAM, ...); }
ReadPropertyUnchecked() читает указатель из байткода и сдвигает instruction pointer. ContainerPtrToValuePtr вычисляет адрес свойства в памяти объекта. Если RESULT_PARAM не null - значение копируется туда. Если null - вызывающий хотел только адрес (для l-value в присваивании).
Custom Thunk: когда C++ лезет в стек VM руками
Обратите внимание на сигнатуру:
DEFINE_FUNCTIONразворачивается вstatic void execXXX(UObject* Context, FFrame& Stack, void* const RESULT_PARAM). Три параметра - объект, стековый фрейм, указатель на результат. Это и есть интерфейс между C++ и VM.Обычная
UFUNCTIONне видит стек напрямую - UE генерирует thunk-обёртку, которая достаёт параметры из стека и передаёт их в вашу функцию как обычные C++ аргументы. Но если пометить функцию какCustomThunk- thunk не генерируется, и вы сами читаете параметры изStack:
DEFINE_FUNCTION(UMyLibrary::execMyCustomFunction) { // Читаем параметры вручную - как это делает сама VM P_GET_PROPERTY(FIntProperty, Value); // int32 из стека P_GET_OBJECT(UObject, Target); // UObject* из стека P_FINISH; // = EX_EndFunctionParms // Или ещё ниже - прямой Step: // Stack.Step(Stack.Object, &SomeResult); }
P_GET_PROPERTY,P_GET_OBJECT,P_FINISH- это макросы, которые вызывают тот жеStack.Step(), что и VM. По сути вы пишете свой опкод-обработчик. Этим пользуютсяUKismetArrayLibrary,UKismetMathLibraryдля wildcard-функций, и именно так чаще всего работают BlueprintInternalUseOnly функции.
Вызовы функций: самая нагруженная группа
Здесь UE различает 6 вариантов вызова. Это не случайно - каждый экономит какую-то проверку:
Опкод | Когда используется | Что экономит |
|---|---|---|
| static + final + native + нет сетевых флагов | Всё: нет context, нет net check, нет vtable lookup |
| final, не-сетевая | Net check |
| виртуальная, не-сетевая | Net check (но ищет по имени) |
| final, возможно сетевая | Vtable lookup |
| виртуальная, возможно сетевая | Ничего - полный путь |
| broadcast делегата | Отдельная логика |
EX_CallMath - самый быстрый путь. Все math-ноды (Add, Multiply, Normalize, VectorLength...) идут через него. VM читает указатель на UFunction, берёт CDO класса, и напрямую вызывает нативную C++ функцию. Ноль overhead на проверки.
EX_VirtualFunction - самый медленный. Читает FName из байткода, ищет функцию через FindFunctionChecked() (хеш-таблица), проверяет net callspace (локально? удалённо? оба?), и только потом вызывает.
Отсюда следует простая закономерность: нода, помеченная в C++ как static, BlueprintPure и без сетевых флагов, пойдёт через EX_CallMath - самый быстрый путь, прямой вызов без проверок. Виртуальный вызов (например, overridable функция из родительского класса) дороже - VM ищет функцию по имени через FindFunctionChecked(). На одном вызове разница - наносекунды. В цикле на сотни акторов - уже заметна.
Вот как компилятор выбирает опкод (файл KismetCompilerVMBackend.cpp, строка 1319):
const bool bFinalFunction = FunctionToCall->HasAnyFunctionFlags(FUNC_Final) || Statement.bIsParentContext; const bool bMathCall = bFinalFunction && FunctionToCall->HasAllFunctionFlags(FUNC_Static | FUNC_Final | FUNC_Native) && !FunctionToCall->HasAnyFunctionFlags(FUNC_NetFuncFlags | ...); const bool bLocalScriptFunction = !FunctionToCall->HasAnyFunctionFlags(FUNC_Native | FUNC_NetFuncFlags | ...); if (bMathCall) Writer << EX_CallMath; else if (bFinalFunction && bLocalScriptFunction) Writer << EX_LocalFinalFunction; else if (bFinalFunction) Writer << EX_FinalFunction; else if (bLocalScriptFunction) Writer << EX_LocalVirtualFunction; else Writer << EX_VirtualFunction;
Логика прозрачна: компилятор проверяет флаги функции и выбирает максимально дешёвый.
Управление потоком
Как у процессора, только проще:
Опкод | Что делает |
|---|---|
| Безусловный переход. Читает 4-байтное смещение, ставит |
| Условный переход. Читает смещение, вычисляет bool. Если false - прыгает |
| Прыжок по вычисленному значению. Для ubergraph dispatch |
| Конец функции. Главный цикл VM останавливается |
FlowStack - отдельный механизм для конструкций с несколькими exec-выходами:
Опкод | Что делает |
|---|---|
| Кладёт адрес на стек FlowStack |
| Снимает адрес с FlowStack, прыгает туда |
| Условный pop |
FlowStack - это не call stack. Это стек "продолжений". Sequence node [A, B, C] работает так:
PushExecutionFlow(адрес_C) // push C PushExecutionFlow(адрес_B) // push B <выполнить A> PopExecutionFlow // pop → B <выполнить B> PopExecutionFlow // pop → C <выполнить C> PopExecutionFlow // pop → пусто → return
ForEachLoop использует PopExecutionFlowIfNot как break: пушит адрес после цикла, и на каждой итерации проверяет условие - если false, pop и выход.
Присваивание
EX_Let (0x0F) - универсальное присваивание. Вызывает Step() для l-value (устанавливает MostRecentPropertyAddress - "куда писать"), потом Step() для r-value (пишет результат по этому адресу).
Специализированные варианты:
EX_LetBool- bool в классе может быть bitfield (упакованы по битам)EX_LetObj- UObject* (черезSetObjectPropertyValue)EX_LetWeakObjPtr- TWeakObjectPtr
Контекст: "через точку"
EX_Context (0x19) - когда вы тянете провод от объекта и вызываете на нём функцию. VM вычисляет объект, проверяет на None:
Если объект валиден - выполняет следующую инструкцию в его контексте
Если
None- пропускает по смещению, бросает "Accessed None trying to read property..."
EX_Context_FailSilent (0x1A) - то же, но без ошибки. Генерируется для функций без выходных значений.
Константы
Для каждого типа - свой опкод: EX_IntConst (int32 вшит в байткод), EX_FloatConst, EX_DoubleConst, EX_StringConst, EX_NameConst, EX_ObjectConst (указатель на UObject), EX_True/EX_False, EX_Self (текущий объект), EX_NoObject (nullptr).
Служебные
EX_EndFunctionParms(0x16) - маркер конца параметров. Количество параметров не закодировано явно - VM читает аргументы один за другим, пока не встретит этот маркер.EX_Nothing(0x0B) - NOP. Placeholder для пустого return value.EX_EndOfScript(0x53) - конец байткода. Если VM дойдёт до него - fatal error (нормальный код завершается черезEX_Return).EX_Breakpoint(0x50) - точка остановки. В editor - вызывает дебаггер. В runtime - NOP.
3. Главный цикл - 5 строк, которые исполняют весь Blueprint
GNatives[]: таблица dispatch
// ScriptCore.cpp, строка 95 FNativeFuncPtr GNatives[EX_Max]; // 255 слотов
Массив из 255 указателей на функции. Индекс - значение опкода. Заполняется при старте через макрос:
// Для каждого опкода: IMPLEMENT_VM_FUNCTION(EX_Jump, execJump); // Развернётся в: GNatives[0x06] = &UObject::execJump;
Незанятые слоты указывают на execUndefined - она выведет ошибку с номером опкода и смещением в байткоде.
ProcessLocalScriptFunction: главный цикл
// ScriptCore.cpp, строки 1203-1302 void ProcessLocalScriptFunction(UObject* Context, FFrame& Stack, RESULT_DECL) { // ... защита от бесконечной рекурсии ... // ========== ГЛАВНЫЙ ЦИКЛ VM ========== while (*Stack.Code != EX_Return) { Stack.Step(Stack.Object, Buffer); } // ===================================== // Обработка return value Stack.Code++; // пропустить EX_Return if (*Stack.Code != EX_Nothing) Stack.Step(Stack.Object, RESULT_PARAM); // вычислить возвращаемое значение }
А Step():
// ScriptCore.cpp, строки 506-510 void FFrame::Step(UObject* Context, RESULT_DECL) { int32 B = *Code++; (GNatives[B])(Context, *this, RESULT_PARAM); }
Всё. Цикл + dispatch. Вся магия - в обработчиках.
FFrame: стековый фрейм VM
К��ждый вызов Blueprint-функции создаёт FFrame - аналог stack frame в C++:
struct FFrame : public FOutputDevice { UFunction* Node; // какая функция исполняется UObject* Object; // на каком объекте (this) uint8* Code; // instruction pointer - текущая позиция в байткоде uint8* Locals; // блок памяти для локальных переменных FFrame* PreviousFrame; // предыдущий фрейм (linked list) FlowStackType FlowStack; // стек "продолжений" для Sequence/ForEach FOutParmRec* OutParms; // linked list out-параметров FProperty* MostRecentProperty; // последнее прочитанное свойство uint8* MostRecentPropertyAddress; // его адрес (используется как l-value) };
Лирическое отступление про букву F. В UE префикс
Fставится перед структурами и не-UObject классами:FString,FVector,FName,FFrame. Официальная версия - это просто конвенция. Но в случаеFFrameсовпадение слишком красивое, чтобы его игнорировать: Frame, стековый фрейм. Вряд ли Тим Суини в 1998-м думал "назову-ка я структуру Frame и добавлю F, чтобы получилось FFrame". Но если это случайность - то чертовски удачная.
Code - самое важное поле. Это instruction pointer VM. Step() читает байт по этому указателю и сдвигает его. Все Read*() методы (ReadObject, ReadProperty, ReadName, ReadCodeSkipCount) тоже читают из Code и сдвигают.
Locals - блок памяти для локальных переменных. Размер - UFunction::PropertiesSize. Выделяется через виртуальный стековый аллокатор или alloca.
MostRecentPropertyAddress - хитрый механизм передачи l-value. Когда опкод EX_InstanceVariable читает свойство, он записывает его адрес сюда. Следом опкод EX_Let забирает этот адрес как место назначения присваивания. Это обход ограничения стековой VM - возможность вернуть "ссылку на место в памяти", а не значение.
FlowStack - массив смещений (TArray<CodeSkipSizeType>). Push/Pop. Используется Sequence, ForEachLoop. Не путать с call stack (PreviousFrame) - FlowStack работает внутри одной функции.
Пример: как VM исполняет Health = MaxHealth * 0.5
Допустим, в Blueprint-функции есть такое присваивание. Скомпилированный байткод (упрощённо):
[EX_Let] // присваивание [ptr→Health] // FProperty* - тип l-value [EX_LocalVariable] // куда присвоить [ptr→Health] // адрес Health в Locals [EX_CallMath] // правая часть: MaxHealth * 0.5 [ptr→Multiply_FloatFloat] // UFunction* [EX_LocalVariable] // первый аргумент [ptr→MaxHealth] // адрес MaxHealth в Locals [EX_DoubleConst] // второй аргумент [0.5 как 8 байт] // литерал 0.5 [EX_EndFunctionParms] // конец аргументов [EX_Return] [EX_Nothing] [EX_EndOfScript]
Что делает VM:
Главный цикл:
Step()→ читаетEX_Let→execLet()execLetвызываетStep()для l-value →EX_LocalVariable→execLocalVariable()→ записывает адресHealthвMostRecentPropertyAddressexecLetвызываетStep()для r-value →EX_CallMath→execCallMathFunction()execCallMathFunctionчитает указатель наMultiply_FloatFloat, вызываетStep()для первого параметра →EX_LocalVariable→ значениеMaxHealth→ пишет в буфер параметраStep()для второго параметра →EX_DoubleConst→ читает 0.5 из байткода → пишет в буферЧитает
EX_EndFunctionParms→ конец параметровВызывает нативную
Multiply_FloatFloat(MaxHealth, 0.5)→ результат вRESULT_PARAMВозврат в
execLet→ копирует результат изRESULT_PARAMвMostRecentPropertyAddress(адресHealth)Главный цикл:
Step()→EX_Return→ цикл останавливается
Обратите внимание: "стек операндов" здесь - вложенные вызовы Step(). Каждый обработчик, которому нужен аргумент, вызывает Step() рекурсивно. C++ call stack и есть стек операндов.
ProcessEvent(): точка входа
ProcessEvent() - единственная "дверь" из C++ в Blueprint. Event Tick, Event BeginPlay, OnOverlap, RPC - всё приходит через неё.
void UObject::ProcessEvent(UFunction* Function, void* Parms)
Что она делает:
Проверяет, жив ли объект
Для нативных сетевых функций - определяет callspace (Local / Remote / оба)
EventGraph fast-call - если функция-stub имеет
EventGraphFunction != nullptr, подменяет вызов на прямой вход в ubergraph (об этом - в секции 5 данной статьи)Выделяет память для локальных переменных
Копирует входные параметры
Создаёт
FFrameFunction->Invoke()- для скриптовых функций этоProcessInternal→ProcessLocalScriptFunction(главный цикл)Деструктирует локальные переменные
Защита от зацикливания
VM контролирует два типа циклов:
Runaway loop - слишком много итераций. Каждый EX_Jump / EX_PopExecutionFlow вызывает CheckRunaway(), который инкрементирует счётчик. Лимит: 1 000 000 (настраивается через bp.MaxFunctionStatDepth). Каждые 256 итераций дополнительно проверяется таймер.
Infinite recursion - слишком глубокий call stack. ProcessLocalScriptFunction инкрементирует счётчик рекурсии. Лимит: 120 вызовов (настраивается через bp.ScriptRecurseLimit).
4. От графа к байткоду: компилятор
Три фазы (на самом деле четыре)
Классический компилятор имеет три фазы: Frontend (разбор) → IR (промежуточное представление) → Backend (генерация кода). Blueprint-компилятор добавляет четвёртую - Expansion:
[1] Expansion: K2Node::ExpandNode() → развёрнутый граф [2] Frontend: NodeHandler::Compile() → IR (FBlueprintCompiledStatement) [3] Backend: GenerateCodeForStatement() → байткод (UFunction::Script)
Фаза 1: Expansion
Компилятор обходит все ноды и вызывает K2Node::ExpandNode(). Каждый K2Node может:
Создать промежуточные ноды (
SpawnIntermediateNode<>())Перенести пины (
MovePinLinksToIntermediate())Удалить себя (
BreakAllNodeLinks())
После expansion в графе остаются только стандартные ноды: K2Node_CallFunction, K2Node_IfThenElse, K2Node_VariableGet. Компилятор знает, как с ними работать.
Именно сюда вписывается то, что мы разбирали в прошлой статье: ExpandNode() нашего кастомного K2Node создаёт стандартные ноды и перекидывает на них пины. Компилятор подхватывает их дальше по конвейеру.
Фаза 2: Frontend - генерация IR
Для каждой ноды из LinearExecutionList компилятор вызывает NodeHandler->Compile(). Обработчик генерирует FBlueprintCompiledStatement - одну IR-инструкцию:
struct FBlueprintCompiledStatement { EKismetCompiledStatementType Type; // KCST_CallFunction, KCST_Assignment, ... UFunction* FunctionToCall; // для вызовов FBPTerminal* LHS; // l-value (назначение) TArray<FBPTerminal*> RHS; // аргументы FBlueprintCompiledStatement* TargetLabel; // цель прыжка };
FBPTerminal - один операнд. Может быть литералом (bIsLiteral = true, значение вшито) или переменной (ссылка на FProperty).
Отдельный момент - pure node inlining. Pure-ноды (без exec-пинов) убираются из LinearExecutionList и их statements инлайнятся прямо перед нодой, которая их использует. Поэтому pure-нода может вычисляться несколько раз, если её результат используется в нескольких местах. Не бесплатная абстракция.
Фаза 3: Backend - генерация байткода
GenerateCodeForStatement() - один большой switch:
switch (Statement.Type) { case KCST_CallFunction: EmitFunctionCall(...); break; case KCST_Assignment: EmitAssignmentStatment(...); break; case KCST_GotoIfNot: EmitGoto(...); break; case KCST_PushState: EmitPushExecState(...); break; case KCST_EndOfThread: EmitPopExecState(...); break; case KCST_Return: EmitReturn(...); break; // ... }
Каждый Emit*() записывает опкоды и данные в TArray<uint8>& ScriptArray = Function->Script.
Jump fixup. Прыжки генерируются в два прохода. Первый проход записывает placeholder (0x00000000) вместо адреса цели. После генерации всего байткода PerformFixups() заменяет placeholder-ы на реальные смещения из StatementLabelMap. Классический приём - так же работают линкеры в C++.
В самом конце: Writer << EX_EndOfScript. Байткод готов.
5. Ubergraph - один граф, чтобы править всеми
Проблема
Blueprint может содержать десятки Event-ов: BeginPlay, Tick, OnOverlap, кастомные. Каждый - отдельная точка входа. Если делать каждый отдельной UFunction со своим стековым фреймом и локальными переменными - много мелких функций, много накладных расходов.
Решение: слияние в одну функцию
Все EventGraph-ы сливаются в одну функцию - ubergraph. Единственный параметр: int32 EntryPoint - смещение в байткоде, с которого начать исполнение. Каждый Event компилируется в функцию-stub, которая просто вызывает ubergraph с нужным смещением:
BeginPlay_Stub() → ExecuteUbergraph(смещение=0) Tick_Stub() → ExecuteUbergraph(смещение=147) OnOverlap_Stub() → ExecuteUbergraph(смещение=302) Ubergraph байткод: [0] EX_ComputedJump(EntryPoint) ← вход ... <код BeginPlay> [147] <код Tick> ... [302] <код OnOverlap>
EX_ComputedJump читает int32 и прыгает на это смещение - dispatch по входной точке.
EventGraph fast-call. ProcessEvent() видит, что stub имеет EventGraphFunction != nullptr, и вместо вызова stub-а вызывает ubergraph напрямую. Один вызов вместо двух. Небольшая, но оптимизация.
Persistent frame
У ubergraph - особый режим памяти. Локальные переменные живут не на C++ стеке (который освобождается при выходе из функции), а в persistent frame - блоке памяти, привязанном к экземпляру объекта.
Зачем? Latent-функции. Delay, MoveTo, PlayMontageAndWait - все они "засыпают" посреди ubergraph и "просыпаются" через несколько кадров (или секунд). Между "уснуть" и "проснуться" - стековый фрейм уже давно уничтожен. Persistent frame сохраняет переменные между пробуждениями.
EX_LetValueOnPersistentFrame - специальный опкод, который пишет прямо в persistent frame, а не в стековые Locals.
Из этого следует неочевидный момент: переменные в EventGraph живут дольше, чем в обычных функциях - они хранятся в persistent frame объекта, а не на стеке. Поэтому переменная "помнит" значение между вызовами разных Event-ов. Технически это позволяет тянуть data-пин через весь EventGraph от одного Event-а к другому - и оно будет работать. Но полагаться на это не стоит: вы завязываетесь на порядок вызовов и на то, что между ними никто не перезаписал значение. Локальная переменная в функции или поле класса куда предсказуемее.
6. Что с этим делать: практика
Чеклист: что быстро, а что медленно
Быстро:
Вызов pure static native функции (math-ноды) -
EX_CallMath, прямой вызов без overheadЧтение локальной переменной -
EX_LocalVariable, одна операцияBranch (if/else) -
EX_JumpIfNot, один условный переход
Средне:
Вызов final Blueprint-функции -
EX_LocalFinalFunction, создание FFrame + интерпретацияЧтение свойства объекта -
EX_InstanceVariable, одна индирекция
Медленно (относительно):
Вызов виртуальной Blueprint-функции -
EX_VirtualFunction, поиск по FName + net checkВызов через Context (другой объект) -
EX_Context+ проверка на None + сам вызовPure-нода, используемая в нескольких местах - вычисляется каждый раз заново (inlining)
Когда переносить в C++
Переносить стоит, если:
Функция вызывается сотни раз за кадр (тик каждого актора в цикле)
Тяжёлые математические вычисления (каждая операция - отдельный dispatch через
Step())Вложенные циклы с большим числом итераций
Не стоит переносить, если:
Функция вызывается раз за event (OnBeginPlay, OnOverlap) - overhead VM измеряется в микросекундах
Логика часто меняется (главное преимущество Blueprint)
Функция в основном вызывает нативные функции (сам вызов через
EX_CallMathбыстрый, основное время - в нативном коде)
Как посмотреть байткод своего Blueprint
Добавьте в DefaultEngine.ini вашего проекта:
[Kismet] CompileDisplaysBinaryBackend=true
Перезапустите редактор, откройте любой Blueprint, нажмите Compile. В Output Log (фильтр LogK2Compiler) появится дизассемблированный байткод всех функций класса. Вот реальный вывод для Blueprint двери с StateTree:
Stub-функция. Помните, мы говорили, что Event-ы компилируются в заглушки, которые просто вызывают ubergraph? Вот как это выглядит в байткоде:
[function RequestToggle]: $46: Local Final Script Function (ExecuteUbergraph_BP_StateDoor) $1D: literal int32 2593 $16: EX_EndFunctionParms $4: Return expression $B: EX_Nothing $53: EX_EndOfScript
Три значащих инструкции. $46 (EX_LocalFinalFunction) вызывает ubergraph со смещением 2593. $4 (EX_Return) + $B (EX_Nothing) - возврат без значения. $53 - конец. Больше ничего. Вся логика - в ubergraph.
Вход в ubergraph. ComputedJump + FlowStack:
[function ExecuteUbergraph_BP_StateDoor]: $4C: FlowStack.Push(0xA28); $4E: Computed Jump, offset specified by expression: $0: Local variable of type int32 named EntryPoint
$4C (EX_PushExecutionFlow) кладёт адрес возврата (0xA28 - это самый конец, Return). $4E (EX_ComputedJump) прыгает на смещение из параметра EntryPoint - тот самый literal int32 2593 из stub-а. Дальше VM исполняет код конкретного Event-а.
LetValueOnPersistentFrame. Когда Event принимает параметры, они сохраняются в persistent frame:
[function OnAgentWantsToPass]: $64: LetValueOnPersistentFrame Destination variable: K2Node_Event_Agent, offset: 88 Expression: $0: Local variable of type AActor* named Agent
$64 (EX_LetValueOnPersistentFrame) пишет параметр Agent прямо в persistent frame по смещению 88. Потом stub вызывает ubergraph, и там этот Agent уже доступен - он живёт не на стеке, а в блоке памяти объекта.
Delay с LatentActionInfo. Самое интересное - как VM "засыпает" и "просыпается":
$68: Call Math (KismetSystemLibrary::Delay) $17: EX_Self $1E: literal float 8.000000 $2F: literal struct LatentActionInfo (serialized size: 32) $5B: literal CodeSkipSizeType 0x20 $1D: literal int32 939469936 $21: literal name ExecuteUbergraph_BP_StateDoor $17: EX_Self $30: EX_EndStructConst $16: EX_EndFunctionParms $4D: if (FlowStack.Num()) { jump to FlowStack.Pop(); } else { ERROR!!! }
Видите CodeSkipSizeType 0x20? Это смещение в байткоде ubergraph, куда VM вернётся через 8 секунд. Delay сохраняет это смещение, и когда таймер сработает - вызовет ExecuteUbergraph_BP_StateDoor с EntryPoint = 0x20. ComputedJump перенесёт IP на это место, и исполнение продолжится как ни в чём не бывало. Persistent frame сохранил все переменные.
А $4D (EX_PopExecutionFlow) после Delay - это не "потом продолжить". Delay уже "усыпил" текущий поток, так что PopExecutionFlow переходит к следующему Event-у в FlowStack, или завершает ubergraph.
Если хотите дизассемблировать программно (например, в своём тулзе), есть FKismetBytecodeDisassembler:
#include "ScriptDisassembler.h" FKismetBytecodeDisassembler::DisassembleAllFunctionsInClasses( *GLog, TEXT("BP_MyActor"));
Что дальше
Мы разобрали VM - машину, которая исполняет байткод. Осталась последняя часть: рефлексия. Как VM знает, какие свойства есть у объекта? Как FindFunctionChecked() находит функцию по имени? Как FProperty::ContainerPtrToValuePtr() вычисляет адрес свойства? Всё это - система рефлексии UE, и она тесно переплетена с VM.
В следующей статье разберём рефлексию изнутри - и замкнём цикл: от кастомного K2Node через VM до рефлексии и обратно. Тот самый хук из гиста, который я показывал в первой статье, наконец получит полное объяснение.
