Каждый раз, когда вы соединяете ноды в 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 должна уметь обращаться к разным "местам" в памяти. Для каждого "места" - свой опкод:

Опкод

Что читает

EX_LocalVariable (0x00)

Локальная переменная функции (из FFrame::Locals)

EX_InstanceVariable (0x01)

Свойство объекта (this->Property)

EX_DefaultVariable (0x02)

Значение из CDO (Class Default Object)

EX_LocalOutVariable (0x48)

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 вариантов вызова. Это не случайно - каждый экономит какую-то проверку:

Опкод

Когда используется

Что экономит

EX_CallMath

static + final + native + нет сетевых флагов

Всё: нет context, нет net check, нет vtable lookup

EX_LocalFinalFunction

final, не-сетевая

Net check

EX_LocalVirtualFunction

виртуальная, не-сетевая

Net check (но ищет по имени)

EX_FinalFunction

final, возможно сетевая

Vtable lookup

EX_VirtualFunction

виртуальная, возможно сетевая

Ничего - полный путь

EX_CallMulticastDelegate

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;

Логика прозрачна: компилятор проверяет флаги функции и выбирает максимально дешёвый.

Управление потоком

Как у процессора, только проще:

Опкод

Что делает

EX_Jump (0x06)

Безусловный переход. Читает 4-байтное смещение, ставит Code = &Script[смещение]

EX_JumpIfNot (0x07)

Условный переход. Читает смещение, вычисляет bool. Если false - прыгает

EX_ComputedJump (0x4E)

Прыжок по вычисленному значению. Для ubergraph dispatch

EX_Return (0x04)

Конец функции. Главный цикл VM останавливается

FlowStack - отдельный механизм для конструкций с несколькими exec-выходами:

Опкод

Что делает

EX_PushExecutionFlow (0x4C)

Кладёт адрес на стек FlowStack

EX_PopExecutionFlow (0x4D)

Снимает адрес с FlowStack, прыгает туда

EX_PopExecutionFlowIfNot (0x4F)

Условный 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:

  1. Главный цикл: Step() → читает EX_LetexecLet()

  2. execLet вызывает Step() для l-value → EX_LocalVariableexecLocalVariable() → записывает адрес Health в MostRecentPropertyAddress

  3. execLet вызывает Step() для r-value → EX_CallMathexecCallMathFunction()

  4. execCallMathFunction читает указатель на Multiply_FloatFloat, вызывает Step() для первого параметра → EX_LocalVariable → значение MaxHealth → пишет в буфер параметра

  5. Step() для второго параметра → EX_DoubleConst → читает 0.5 из байткода → пишет в буфер

  6. Читает EX_EndFunctionParms → конец параметров

  7. Вызывает нативную Multiply_FloatFloat(MaxHealth, 0.5) → результат в RESULT_PARAM

  8. Возврат в execLet → копирует результат из RESULT_PARAM в MostRecentPropertyAddress (адрес Health)

  9. Главный цикл: 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)

Что она делает:

  1. Проверяет, жив ли объект

  2. Для нативных сетевых функций - определяет callspace (Local / Remote / оба)

  3. EventGraph fast-call - если функция-stub имеет EventGraphFunction != nullptr, подменяет вызов на прямой вход в ubergraph (об этом - в секции 5 данной статьи)

  4. Выделяет память для локальных переменных

  5. Копирует входные параметры

  6. Создаёт FFrame

  7. Function->Invoke() - для скриптовых функций это ProcessInternalProcessLocalScriptFunction (главный цикл)

  8. Деструктирует локальные переменные

Защита от зацикливания

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 до рефлексии и обратно. Тот самый хук из гиста, который я показывал в первой статье, наконец получит полное объяснение.