Spiiin's blog

DasForUE - язык daslang в Unreal Engine

Сделал похожий по архитектуре на 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 относительно наивной реализации (выбросил весь треш, и немного закешировал проверки).

Разделение игровых скриптов/редакторских/тестым#

Сделано только базово. Ещё нужно получше побить не модули.

Тесты#

Привязка и пример обложены тестами, чтобы оставаться устойчивыми к регрессиям.

Итог#

Совсем маленькие примерчики работают, примерчики побольше, как на видео, тоже. Можно переходить к совсем большим примерам, чтобы дотюнить привязку.