- Скриптовые языки в Unreal Engine - разбирал архитектуру подключения скриптового языка к unreal, на примере
Nim
Сделал похожий по архитектуре на Nim и AngelScript враппер для подключния к Unreal Engine языка daslang.
https://github.com/spiiin/DasForUE - обёртка
https://github.com/spiiin/ActionRoguelikeDas/ - пример, портированный с курсов Tома Лумана.
Look, ma, no C++ - пример полностью написан на daslang
Ниже немного девлога про подключение.
Работа с VM Blueprints#
После подключения daslang к движку, первым шагом можно “притвориться” блюпринтом. Это не самый лучший или быстрый способ работы скриптов, но позволяет разобраться, как работает система рефлексии unreal и регистрации скриптовых функций.
Со стороны C++ создаётся граф функции. Например, сокращённый фрагмент из DasBlueprintService.cpp — создание новой функции с аргументом Input:
// BP — создаваемый Blueprint, Method.Name — имя функции.
auto* Graph = FBlueprintEditorUtils::CreateNewGraph(
BP, Method.Name, UEdGraph::StaticClass(), UEdGraphSchema_K2::StaticClass());
FBlueprintEditorUtils::AddFunctionGraph<UClass>(BP, Graph, true, nullptr);
TArray<UK2Node_FunctionEntry*> Entries;
Graph->GetNodesOfClass(Entries);
Entries[0]->CreateUserDefinedPin(TEXT("Input"), ValueType, EGPD_Output);Дальше в граф добавляются выходной узел и вызов C++-моста, который запускает нужную DAS-функцию. Соединяются exec-пины и пины аргументов, Blueprint компилируется — и Unreal видит обычную функцию. Полная реализация — в DasBlueprintService.cpp.
В обратную сторону — из DAS в Unreal — сначала тоже можно идти через reflection. Вот низкоуровневый пример получения координаты актора из репозитория:
options gen2
require ue_foundation
[export]
def main(input:int):int {
let call = ue_begin_call(ue_self_object(), "K2_GetActorLocation")
ue_invoke_call(call)
let x = ue_result_double(call, "ReturnValue.X")
ue_release_call(call)
return int(x)
}ue_begin_call находит UFunction и готовит память параметров, ue_invoke_call вызывает ProcessEvent, а результат читается по пути внутри возвращаемой структуры. Это не запись байт-кода Blueprint вручную, а работа с его механизмом вызова через C++-мост.
Функции и макросы для упрощения вызовов#
Работа со стековой виртуальной машиной на такой уровне — это слишком сложно, поэтому вызовы можно обернуть
Тот же вызов с типизированной ссылкой на актора:
options gen2
require ue_native_gameplay
[export]
def main(input:int):int {
let actor = ue_ref(type<ActorRef>, ue_self_object())
let location = actor.K2_GetActorLocation()
return int(location.x)
}Теперь сигнатура известна при компиляции: не нужно вручную создавать frame, выбирать тип результата и освобождать память. Обёртка делает это сама. Вызов может идти через reflection либо через нативную привязку, если для него зарегистрирована такая замена — пользовательский синтаксис одинаковый.
DSL#
Так как в daslang есть макросы, можно повторить парсер C++ (и сделать его удобнее!), для того, чтобы размечать атрибутами и мета-информацией код, который внешне выглядит как daslang-код
Например, актор-счётчик с редактируемым полем, Blueprint-функцией и событием:
options gen2
require ue_native_gameplay
[ue_multicast]
struct FScoreChanged {
signature:function<(Score:int):void>
}
[ue_class(Blueprintable)]
class Counter : Actor {
properties = ue_properties(EditAnywhere, BlueprintReadWrite, Category="Score") {
// Текущее количество очков. Комментарий станет tooltip в редакторе.
var Score:int = 0
//можно объявлять более одного свойства, у группы свойств будет одинаковая мета-информация
}
events = ue_properties(BlueprintAssignable, Category="Score") {
var OnScoreChanged:FScoreChanged
}
def BeginPlay():void {
self.OnScoreChanged.AddUFunction(ue_self_object(), self.PrintScore)
}
[ue_function(BlueprintCallable)]
def AddScore(amount:int):void {
self.Score += amount
self.OnScoreChanged.Broadcast(self.Score)
}
[ue_function(BlueprintCallable)]
def PrintScore(score:int):void {
print("Score: {score}\n")
}
}Score становится настоящим UPROPERTY, AddScore — UFunction, а OnScoreChanged — Event Dispatcher, к которому можно подключиться и из Blueprint. BeginPlay распознаётся как lifecycle-метод без дополнительной аннотации. Группа ue_properties задаёт общие флаги сразу нескольким полям; комментарии к отдельным полям превращаются в подсказки. Декларации собираются макросами в описание класса, из которого редактор создаёт Blueprint-ассет.
Похожие примеры в репозитории: актор с событиями и управляемый Character с камерой и Enhanced Input.
Нативные вызовы#
Следующий шаг — привязка нативных функций unreal, чтобы вызывать их напрямую, а не через отражение (это раз в 10 быстрее). Примерный объём того, что привязывается нативно для быстрых вызовов я подсмотрел в AngelScript.
В этом примере жизненный цикл описан через DSL, а координаты читаются и меняются прямыми нативными вызовами:
options gen2
require ue_native_gameplay
require ue_engine
[ue_class(Blueprintable)]
class MovingActor : Actor {
properties = ue_properties(EditAnywhere, BlueprintReadWrite) {
// Скорость движения вдоль X, в сантиметрах в секунду.
var Speed:double = 100.0lf
}
def Tick(delta:float):void {
let actor = as_AActor(ue_self_object())
var location = actor.get_actor_location()
location.x += self.Speed * double(delta)
actor.set_actor_location(location)
}
}get_actor_location и set_actor_location здесь — функции модуля ue_engine: они вызывают C++ напрямую, без ProcessEvent и reflection-frame. При этом ссылка проверяется на валидность. Для части методов поддержана и автоматическая маршрутизация: например, привычный self.K2_GetActorLocation() может быть заменён нативной привязкой с тем же результатом. Наличие слова native в названии ue_native_gameplay само по себе не означает прямой вызов: этот модуль также предоставляет доступ к отражённым методам нативных классов.
Привязка существующего метода Unreal выглядит так:
#include "DasNativeBinds.h"
#include "GameFramework/Actor.h"
DAS_NATIVE_BINDS(UEEngine, "ue_engine")
{
Binds.Class<AActor>(TEXT("AActor"))
.Method<&AActor::GetActorLocation>(TEXT("get_actor_location"))
.Reflects(TEXT("K2_GetActorLocation"));
}После require ue_engine скрипт вызывает actor.get_actor_location(), который напрямую вызывает AActor::GetActorLocation(). Reflects дополнительно связывает привязку с отражённым методом K2_GetActorLocation, чтобы вызовы через типизированные gameplay-ссылки тоже могли использовать нативный путь без ProcessEvent.
Больше примеров — в документации по нативным привязкам.
Live-reloading#
Достаточно много работы заняла связка с системой перезагрузки Unreal. Кроме простого отслеживания изменений в файлах в редакторе нужно было добиться, чтобы изменения в скриптах отражались как в редакторе — послать сообщение об обновлении самому редактору и прописать логику обновления — например, редактор позволяет обойти ноды блюпринтов и отобразить на них изменения, проверить связи и обновить их (например, часто можно автоматически обновить связи у совместимых типов). Некоторые изменения можно сделать и при запущенной в PIE-режиме игре без остановки (если они касаются кода с рефлексией).
Инфраструктура daslang#
С редактором можно совместить и фичи dascript - AoT-компиляцию в нативный код (пока только нативных вызовов, вызовы через рефлексию не нативизируются), оптимизацию времени компиляции (сохранение AST) и другое, отображение ошибок компиляции в UE. Скорее всего работает и отладчик, но его не проверял.
Оптимизация#
Тоже достаточно объёмная часть работы — добиться приемлимо быстрых вызовов через рефлексию, после шаманств с ускорениями получилось разогнать раз в 500 относительно наивной реализации (выбросил весь треш, и немного закешировал проверки).
Разделение игровых скриптов/редакторских/тестым#
Сделано только базово. Ещё нужно получше побить не модули.
Тесты#
Привязка и пример обложены тестами, чтобы оставаться устойчивыми к регрессиям.
Итог#
Совсем маленькие примерчики работают, примерчики побольше, как на видео, тоже. Можно переходить к совсем большим примерам, чтобы дотюнить привязку.
