o2 dev
108 subscribers
49 photos
4 videos
25 files
54 links
About o2 engine development
加入频道
Итак, все начинается с выбора языка. В мире полно скриптовых языков - LUA, LUAu, Squirrel, AngleScript и так далее. Обычно в подобные движки встраивают LUA. Он маленький, простой и довольно производительный. Но на нем катастрофически мало пишут людей. Да, его можно быстро изучить. Но изучить и уже знать - две большие разницы. Поэтому я искал что-то уже распространенное, но в тоже время очень маленькое и простое для встраивания.

Я смотрел на два языка - JS и C#. Рантаймы для обоих языков жирные. Для JS казался самым очевидным выбор google V8, который требовал минимум 6mb бинаря, что крайне много в мобильном геймдеве. C# вроде как развивается, mono стал общедоступным и тоже был интересным кандидатом. Тем более что уже есть целая армия Unity-разработчиков, плотно подсевших на C#. Но его рантайм тоже был тяжелым и сложным для встраивания.

Из этих двух я больше склонялся к JS по нескольким причинам:
- это язык не как в Unity
- web-сообщество огромное, и они тоже хотят делать игры
- сам по себе JS не строго типизированный, но TypeScript, который транслируется в JS, уже строго типизированный. Таким образом внедрив JS, можно было бы использовать еще и TypeScript

Потом как-то пособеседовал одного кандидата на работе. Кажется он работал в playtika, которые тоже делают мобилки. И он рассказал, что уже несколько лет пишут на JS. Мне, как разработчику своего движка, да и как техдиректору стало интересно, что да как.

Он рассказал что у них движок, похожий на Cocos, написанный на С++, в который встроен JS-движок JerryScript. Бам! О таком движке я не знал. Он маленький, заточенный на производительность. Из похожих я видел только QuickJS, но у него просто отсутствует документация. И я стал изучать подробнее. Все же если целая контора использует его в продакшене, это что-то да значит
Я бегло изучил API и примеры, собрал тестовый проект и все заработало с пол-пинка. Это круто, потому что собрать какой-нибудь V8 весьма не просто и долго
Итак, я выделил 2 основные вещи для поддержания скриптинга: это подсистема скриптового движка и класс-обертка над скриптовым значением.

У jerry для скриптового значения используется jerry_value_t. Так как все API C-шное, то и никаких удобных классов естественно нет. Есть куча C-шных функций. Это значение и C-шные функции я обернул в свой класс, ScriptValue
Подсистема скриптового движка довольно простая и оперирует тем же ScriptValue. Она умеет парсить скрипт, выполнять скрипт, возвращать ScriptValue глобального неймспейса, вызывать очистку мусора и подключать отладчик.
Кстати, один из плюсов jerry еще и наличие готового отладчика. Достаточно поставить плагин для VS Code, и вызвать коннект из кода. Вауля, отладчик прицепился, можно посмотреть что и как выполняется. Однако, есть один минус - он плохо показывает содержимое объектов. Точнее, он их не показывает, пишет просто [object] и все. Кажется, авторы jerry здесь как-то поленились и не довели передачу информации до конца. Что ж, не страшно, добавим сами!
Остановимся подробнее на ScriptValue. Кажется что это простой класс, но он должен уметь делать многое:
- хранить в себе скриптовое значение
- хранить в себе функцию, свободную или функцию класса
- хранить в себе указатель на С++ объект
- понимать что за тип там хранится
- уметь кастить содержимое в нужный тип
- понимать что за тип С++ объекта в нем хранится
- вызывать функции
- работать с полями объектов: добавлять, удалять, итерировать
- работать со значением как с массивом

где-то там держится за сердце один из адептов SOLID... Но это API оборачивает скриптовое значение, которое по факту все это должно уметь. Ведь в JS переменная может содержать в себе что угодно. Разберемся сначала с тем что есть переменная в JS
В ней может хранится либо примитивный тип (int, number, string, bigInt), функция, объект или массив. По сути такой вариант, который может мимикрировать. Это и есть динамическая типизация. В переменной хранится значение какого-то типа, и она в любой момент может поменять свой тип
ScriptValue хранит внутри jerry_value_t, который по сути является неким указателем на значение в контексте jerry. Важно понимать как работать с этим значением.

Его нужно создавать и удалять. Иначе - ошибка в рантайме. Jerry подсчитает количество ссылок, и скажет тебе что ты забыл освободить чего-то. Создавать значение можно сразу нужного типа, например jerry_create_undefined или jerry_create_object. По окончанию использования его нужно освободить, вызвав jerry_release_value.

Чтобы скопировать значение (простые типы копируются, на объекты копируется ссылка) нужно использовать jerry_acquire_value.

Оборачиваем эти функции где нужно и уже не нужно заботится об освобождении jerry_value_t
Для передачи и получения значений я использовал такой же паттерн со специализацией шаблонов, что и для своего DataValue.

В самом классе объявляются шаблонные функции operator T и operator=(T), которые принимают в себя любой тип. Внутри происходит специализация шаблона Converter<T>. Далее для разных типов T пишутся специализации Converter<type> со всякими SFINAE-перделками.

Таким образом описывается различное поведение для разных типов при присваивании и получении значения
С простыми типами все просто, используем готовые API jerry. Со всем остальным уже интереснее.

Начнем с не совсем простых типов, например Vec2 или Rect. У них внутри есть свои поля типа x, y; и есть какой-то функционал. Нужно чтобы эти же поля были и в скрипте, и еще и похожий функционал.

Прокидывать нативные классы в JS мы не будем, тк это очевидный оверхед. Конвертация между JS и C++ нужна только на моменте стыка вызовов функций. Внутри JS гораздо оптимальнее работать со своими структурами данных, чем с биндингами нативных объектов. А конвертить уже на этапе передачи или полчения значения в/из скрипта

Поэтому пишем JS-классы этих типов, чтобы уметь с ними работать в скриптах. Теперь нужно их как-то замапить на С++ объекты. Здесь остановимся на том, что есть класс в JS и как он устроен внутри
Классы в JS - это чисто синтаксический сахар. На самом деле их нет. Объекты классов - это обычные объекты с полями внутри, но с ссылкой на некий прототип. Этот прототип один для всех классов одного типа. А сами классы - это даже не объекты, а специальные constructor функции. Если в функции есть работа с this, то это constructor-функция. Само объявление класса - это замыкание, которое возвращает такую constructor-функцию. Рассмотрим пример:

class MyClass
{
constructor() { this.abc = "abc" }
myFunction() { print(this.abc) }
}

он под капотом представляет себя что-то такое:

MyClass = (function() 
{
function MyClass() { this.abc = "abc" }
MyClass.prototype.myFunction = function() { print(this.abc) }

return MyClass;
})
();
выглядит хитрожопно, но попробуем разобраться. Мы объявляем функцию и сразу же ее вызываем. Это нужно чтобы спрятать внутренности от всех. Такой вот метод сокрытия от публичности. Внутри мы объявляем функцию, которая является конструктором, тк она работает с this. Далее к прототипу этой функции (который потом будет прототипом объекта класса) прицепляем нашу функцию. И в конце в нашей базовой функции возвращаем эту хитрую функцию-конструктор

В самом языке есть ключевое слово new, которое работает с функциями-конструкторами, делая экземпляр класса.

Спрашиваете, зачем, черт тебя дери, я это узнал?? О мерзкий JS... Но это очень важно понимать чтобы понять как прокинуть классы в JS и наоборот
Вернемся обратно к теме прокидывания Vec2 и Rect в JS и обратно. Когда мы передаем значение из C++ в JS, то в JS мы должны получить экземпляр класс Vec2, описанного в JS. Для этого мы конструируем объект, добавляем туда необходимые поля (x и y, например) и вручную назначаем прототип этого объекта, такой же как и прототип класса Vec2. Для этого нам нужно взять прототип из JS, что я сделал несколько костыльно: o2Scripts.Eval("Vec2.prototype"). Затем передать его в ScriptValue через jerry_set_prototype.

Из JS в C++ конвертить просто, мы уже знаем тип из плюсов и просто достаем поля их объекта (x, y)
Теперь перейдем к С++ объектам, которые нужно передать в скрипт. Например, у нас есть такой класс:
class TestInside: public ISerializable
{
public:
float mFloat = 1.2f; // @SERIALIZABLE @SCRIPTABLE
String mString = String("bla bla"); // @SERIALIZABLE @SCRIPTABLE
bool mBool = true; // @SERIALIZABLE @SCRIPTABLE

ComponentRef mComponent; // @SERIALIZABLE @SCRIPTABLE
Ref<RigidBody> mRigidBody; // @SERIALIZABLE @SCRIPTABLE

SERIALIZABLE(TestInside);
};

нужно уметь прокидывать такой класс в скрипт, уметь конструировать его из скрипта, обращаться к полям класс и вызывать методы
jerry предоставляет способ прокинуть в скриптовое значение поинтер на свои данные. Но сделано это, конечно, в великолепной С-шной манере. Для этого используется функция:
void jerry_set_object_native_pointer (const jerry_value_t obj_val,
void *native_pointer_p,
const jerry_object_native_info_t *native_info_p);

рассмотрим ее параметры:
- const jerry_value_t obj_val - это, собственно, в какое скриптовое значение добавлять данные, тут все понятно
- void *native_pointer_p - указатель на данные, ништяк
- const jerry_object_native_info_t *native_info_p - поинтер на структуру, описывающую способ владения данными

последнее - самое интересное и самое не удобное. Это указатель на некий контрлльный блок, общий для определенного типа данных, который управляет освобождением данных из native_pointer_p

этот блок нужен, тк в JS работает Garbage Collector, который в некий момент может решить убить скриптовое значение, а вместе с ним нужно освободить наши кастомные данные из поинтера

но вот незадача, в структуре этого блока функция Free - статичная. Бадум-тсс, это значит что под каждый тип кастомных данных нужно писать свою структуру управляющего бока. Удобна, что скажешь

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

вот здесь можно посмотреть как нативные данные передаются в скриптовое значение
Для удобной работы с нативными данным в ScriptValue есть еще несколько функций:

bool IsObjectContainer() const; - чтобы понять хранится ли какой-то объект внутри впринципе
const Type* GetObjectContainerType() const; - чтобы получить тип хранимого объекта
void* GetContainingObject() const; - получить сырой указатель на объект
отдельный момент про владение нативным объектом. Пока что у меня сделано не очень хорошо, тк владение не регламентировано жестко и могут возникнуть проблемы

Суть в том, что объект создается из нативной части, память управляется вручную. При этом есть GC в JS, который тоже как-то управляет памятью. Соответственно могут быть ситуации, когда GS должен удалить нативный объект, икогда не должен. По сути это определяется тем, владеет ли ScriptValue нативным объектом или нет. Если владеет, то его судьба полностью подвластна GC. Если нет - то ScriptValue просто хранит поинтер на объект, но никак его не удаляет.

Отсюда могут возникнуть проблемы. Например, прокинули объект в скрипты и убили его. Скрипт не узнает об этом. Или наоборот, скрипт владеет объектом, а мы его прибили из нативного кода.

Сейчас владение объектов по сути на совести разработчика. Но, на мой взгляд, есть более правильный способ владения объектом - через умные указатели. Если скрипт владеет объектом, то он держит сильную ссылку, если нет - слабую. Таким образом и скрипт, и нативный код защищены от непредвиденного удаления объекта

Такой подход я сделаю когда весь движок переведу на умные указатели. Да да, у меня ручное управление паматью, и оно мне не нравится
так, прокинули объект в скрипт. Но из скрипта с ним ничего не сделать, тк это просто объект без полей и плюсовые поля класса и методы никак не соотносятся со скриптом. Разберем сначала как работает прокидывание полей класса в скрипт

По сути нам нужно в объект добавить проперти, который маппится на указатель поля из нативного объекта. Либо эта проперти является оберткой над паркой setter/getter.

Чтобы сделать кастомизируемое проперти, нужно использовать специальную функцию jerry_define_own_property. Она добавляет проперти в объект, но с неким описанием как это поле работает - jerry_property_descriptor_t

Оно включает в себя параметры конфигурации поля, в котором можно задать сеттер и геттер поля. Их мы и заиспользуем. Эти сеттер и геттер - тоже скриптовые значения, которые должны быть функциями с определенной сигнатурой

Для этого определим указатели на нативные функции, к которым прицепим нативный контейнер с интерфейсом сеттера или геттера, в котором уже будем работать с указателем на поле нативного объекта. Подробнее можно глянуть в функции SetPropertyWrapper()

В результате этих операций мы получаем проперти в объекте, который вызывает специальные сеттер и геттер, которые уже работают с указателем на значение
Рядом же есть реализация проперти не через поинтер, а через функции
Так, у нас теперь есть объект с полями. Откуда они там зарегистрируются - попозже, вкратце через кодген. Теперь нам нужны функции

Здесь jerry API тоже нас не балует удобством и предоставляет интерфейс биндинга функции в скриптовое значение. Статичной функции... Снова пишем обертки!

По факту мы действительно биндим одну единственную статичную функцию на все типы функций, но саму функцию запихиваем через нативный объект. Да, скриптовое значение, которое является функцией, может еще и держать поинтер на кастомные данные.

Делаем уже по привычному пути и даже используем тот же интерфейс для хранения обертки над функцией. А в статичной функции обращаемся к этому контейнеру и вызываем функцию

Пока все просто. Но еще нужно передать параметры! Тут все интересно. Ведь на стороне JS список параметров - это массив jerry_value_t . На стороне С++ - это конкретная сигнатура функции. Пахнет магией шаблонов

При вызове функции из контейнера нам нужно упаковать параметры из JS в tuple<> для передачи в нативную функцию. Каждый отдельный параметр мы просто кастим через оператор каста в ScriptValue. А чтобы их все проитерировать, мы итерируем параметры из сигнатуры нативной функции через шаблоны - UnpackArgs

Эта функция принимает в качестве параметров шаблона индекс параметра! и тип аргументов. Внутри рекурсия с приростом индекса параметра и ображение к элементу tuple через std::get<idx>.

Оборачиваем всякой шелухой на удаление ссылок из типов, проверяем как именно должна вызываться фукнция - с возвращаемым значением или нет, и вызываем нативную функцию через std::apply(). В нее передаем указатель на функцию и tuple с параметрами

Вауля, мы умеем прокинуть нативную функцию из С++ в JS и вызвать ее из JS. Немножко сложнее вызываются функции класса, там нужно еще и обработать this
А как вызвать из С++ функцию из JS? Процесс идет наоборот, но немного проще.

Сначала объявляем интерфейс вызова через передачу параметров в виде скриптовых значений - InvokeRaw. В ней просто дергаем jerry API - jerry_call_function

Ну а чтобы иметь нормальный плюсовый интерфейс вызова функции, нам нужны variadic template args и их упаковка в массив ScriptValue. Для этого используем функцию PackArgs с магией итерирования по variadic templates.

В результате имеем человеческий вызов Invoke, который сам сконвертит параметры и передаст в скрипт.