Как стать автором
Обновить

Как я встраивал JS в свой игровой движок

Время на прочтение14 мин
Количество просмотров7.6K

Писать на С++ игры долго и дорого, но при этом по перфомансу получается хорошо. Но далеко не всё в играх, требовательно к производительности, особенно 2D. Например всякие окошечки не производят тяжелых расчетов внутри. А на больших проектах они могут занимать до 80% всего объема кода. И на С++ есть проблемы на продакшене - если где-то крешится или бажит, приходится перезаливать приложение.

Скрипты - решение всех бед. И код писать проще, и обновлять приложение можно через дозагрузку (да-да, сторы явно запрещают это, но на деле все так делают).

Почему JS?

В мире полно скриптовых языков - LUA, LUAu, Squirrel, AngleScript и так далее. Обычно в подобные движки встраивают LUA. Он маленький, простой и довольно производительный. Но на нем катастрофически мало пишут людей. Да, его можно быстро изучить. Но изучить и уже знать - две большие разницы. Поэтому я искал что-то уже распространенное, но в тоже время очень маленькое и простое для встраивания.

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

Из этих двух я больше склонялся к JS по нескольким причинам:

  • это язык не как в Unity;

  • web-сообщество огромное, и они тоже хотят делать игры;

  • сам по себе JS не строго типизированный, но есть TypeScript, который транслируется в JS, и он уже строго типизированный. Таким образом внедрив JS, можно было бы использовать еще и TypeScript.

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

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

Я бегло изучил API и примеры, собрал тестовый проект и все заработало с пол пинка. Это круто, потому что собрать какой-нибудь V8 весьма не просто и долго.

Как все устроено

Итак, я выделил 2 основные вещи для поддержания скриптинга: это подсистема скриптового движка и класс-обертка над скриптовым значением.

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

Кстати, один из плюсов jerry еще и наличие готового отладчика. Достаточно поставить плагин для VS Code, и вызвать коннект из кода. Вауля, отладчик прицепился, можно посмотреть что и как выполняется. Однако, есть один минус - он плохо показывает содержимое объектов. Точнее, он их не показывает, пишет просто [object] и все. Кажется, авторы jerry здесь как-то поленились и не довели передачу информации до конца. Что ж, не страшно, добавим сами!

ScriptValue всему голова!

У jerry для скриптового значения используется jerry_value_t. Так как все API C-шное, то и никаких удобных классов естественно нет. Есть куча C-шных функций. Это значение и C-шные функции я обернул в свой класс, 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 пишутся специализации Converter со всякими SFINAE-штуковинами.

Пример специализации
	template<typename _type, typename _enable = void>
	struct Converter
	{
		static constexpr bool isSupported = true;
		using __type = typename std::conditional<std::is_same<void, _type>::value, int, _type>::type;

		static void Write(const __type& value, ScriptValue& data);
		static void Read(__type& value, const ScriptValue& data);
	};

  template<typename _key, typename _value>
	struct Converter<Map<_key, _value>>
	{
		static constexpr bool isSupported = true;

		static void Write(const Map<_key, _value>& value, ScriptValue& data)
		{
			data.jvalue = jerry_create_object();

			for (auto& kv : value)
				data.SetProperty(ScriptValue(kv.first), ScriptValue(kv.second));
		}

		static void Read(Map<_key, _value>& value, const ScriptValue& data)
		{
			if (data.GetValueType() == ValueType::Object)
			{
				value.Clear();
				data.ForEachProperties([&](const ScriptValue& name, const ScriptValue& value) {
					value[name.GetValue<_key>()] = name.GetValue<_value>();
				});
			}
		}
	};

Таким образом описывается различное поведение для разных типов при присваивании и получении значения.

С простыми типами все просто, используем готовые API jerry. Со всем остальным уже интереснее.

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

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

Поэтому пишем JS-классы этих типов, чтобы уметь с ними работать в скриптах. Теперь нужно их как-то замапить на С++ объекты. Здесь немного остановимся и разберемся что есть класс в JS и как он устроен внутри.

Классы в JS

Классы в JS - это чисто синтаксический сахар. На самом деле их нет. Объекты классов - это обычные объекты с полями внутри, но с ссылкой на некий прототип. Этот прототип один для всех классов одного типа. А сами классы - это даже не объекты, а специальные 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, что я сделал несколько костыльно (не нашел нужных функций API): o2Scripts.Eval("Vec2.prototype"). Затем передать его в ScriptValue через jerry_set_prototype. Таким образом мы при передаче Vec2 из С++ мы получаем Vec2 уже в JS, имеющий все нужные методы для работы.

Из JS в C++ конвертить просто, мы уже знаем тип из плюсов и просто достаем поля их объекта (x, y):

	template<>
	struct Converter<Vec2F>
	{
		static constexpr bool isSupported = true;

		static void Write(const Vec2F& value, ScriptValue& data)
		{
			data.jvalue = jerry_create_object();
			data.SetPrototype(*ScriptValuePrototypes::GetVec2Prototype());
			data.SetProperty("x", ScriptValue(value.x));
			data.SetProperty("y", ScriptValue(value.y));
		}

		static void Read(Vec2F& value, const ScriptValue& data)
		{
			value.x = data.GetProperty("x");
			value.y = data.GetProperty("y");
		}
	};

Теперь перейдем к С++ объектам, которые нужно передать в скрипт. Например, у нас есть такой класс:

class Test: public ISerializable
{
    SERIALIZABLE(TestInside);
    
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
    
public:
    Test(); // @SCRIPTABLE
    
    int DoSmth(float param); // @SCRIPTABLE
};

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

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.

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

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

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

Управляющий блок
	struct IDataContainer
	{
		bool isDataOwner = true;

		virtual ~IDataContainer() = default;

		virtual void* GetData() const { return nullptr; }
		virtual IObject* TryCastToIObject() const { return nullptr; }
		virtual const Type* GetType() const { return nullptr; }
	};

...
  
	struct DataContainerDeleter
	{
		jerry_object_native_info_t info;

		DataContainerDeleter();
		static void Free(void* ptr);
	};

	static DataContainerDeleter& GetDataDeleter();

...
  
	ScriptValueBase::DataContainerDeleter& ScriptValueBase::GetDataDeleter()
	{
		static DataContainerDeleter deleter;
		return deleter;
	}

	ScriptValueBase::DataContainerDeleter::DataContainerDeleter()
	{
		info.free_cb = &Free;
	}

	void ScriptValueBase::DataContainerDeleter::Free(void* ptr)
	{
		delete (IDataContainer*)ptr;
	}

Здесь можно посмотреть как нативные данные передаются в скриптовое значение.

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

  • bool IsObjectContainer() const; - чтобы понять хранится ли какой-то объект внутри впринципе;

  • const Type* GetObjectContainerType() const; - чтобы получить тип хранимого объекта;

  • void* GetContainingObject() const; - получить сырой указатель на объект.

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

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

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

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

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

Поля классов

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

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

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

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

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

Рядом же есть реализация property не через указатель, а через функции.

Проброс функции из C++ в JS

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

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

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

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

Вызов функции
	jerry_value_t ScriptValue::CallFunction(const jerry_value_t function_obj,
												const jerry_value_t this_val,
												const jerry_value_t args_p[], const jerry_length_t args_count)
	{
		void* ptr = nullptr;
		jerry_get_object_native_pointer(function_obj, &ptr, &GetDataDeleter().info);

		IFunctionContainer* container = dynamic_cast<IFunctionContainer*>((IDataContainer*)ptr);
		return container->Invoke(this_val, (jerry_value_t*)args_p, args_count);
	}

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

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

UnpackAgs

	template<size_t _i /*= 0*/, size_t _j /*= 0*/, typename... _args>	
	void ScriptValueBase::UnpackArgs(std::tuple<_args ...>& argst, jerry_value_t* args, int argsCount)
	{
		if (_j < argsCount)
		{
			ScriptValue tmp;
			tmp.AcquireValue(args[_j]);
			std::get<_i>(argst) = tmp.GetValue<std::remove_reference<decltype(std::get<_i>(argst))>::type>();

			if constexpr (_i + 1 != sizeof...(_args))
				UnpackArgs<_i + 1, _j + 1>(argst, args, argsCount);
		}
	}

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

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

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

Проброс функции JS в C++

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

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

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

PackArgs
		template<typename ... _args>
		static void PackArgs(Vector<ScriptValue>& argsValues, _args ... args)
		{
			([&](auto& arg) { argsValues.Add(ScriptValue(arg)); } (args), ...);
		}

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

		template<typename _res_type, typename ... _args>
		_res_type Invoke(_args ... args) const;

Регистрация С++ классов в JS

Для начала нужно как-то объявить конструктор. Помните выше описывал как работают конструкторы в JS? Вот нам нужно все то же самое сделать для нативных классов.

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

Функция определяется тут. В ней мы конструируем объект и с помощью кодгена прокидываем данные о полях и функциях, используя вышеперечисленное API ScriptValue

Определение функции-конструктора для класса
	template<typename _object_type, typename ... _args>
	void ScriptConstructorTypeProcessor::FunctionProcessor::Constructor(_object_type* object, Type* type)
	{
		ScriptValue thisFunc;
		thisFunc.SetThisFunction<void, _args ...>(Function<void(ScriptValue thisValue, _args ...)>(
			[](ScriptValue thisValue, _args ... args) {
				_object_type* sample = mnew _object_type(args ...);
				thisValue.SetContainingObject(sample, true);

				if constexpr (std::is_base_of<IObject, _object_type>::value && !std::is_same<IObject, _object_type>::value)
				{
					ReflectScriptValueTypeProcessor processor(thisValue);
					_object_type::template ProcessType<ReflectScriptValueTypeProcessor>(sample, processor);
				}
			}));

		ScriptConstructorTypeProcessor::RegisterTypeConstructor(type, thisFunc);
	}

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

В этот процессор на вход попадает ScriptValue и указатель на класс. Он заполняет ScriptValue полями из класса и функциями. Заполняет только теми, у которых есть атрибут @SCRIPTABLE.

Как скрипты используются для написания логики

У меня есть граф сцены, логика заключена в компонентах. Для скриптов добавлен ScriptableComponent, который держит в себе путь до скрипта и ScriptValue-инстанс класса из этого скрипта. В нем вызывает всякие OnStart, Update, OnEnabled/Disabled функции из ScriptValue. Сам инстанс обозначен как serializable. Для него написан конвертор, который просто перебирает все property и пишет в DataValue (обертка над json).

Редактор

Редактор Scriptable Component
Редактор Scriptable Component

Чтобы отобразить все параметры в редакторе есть специальное поле редактора ScriptValueProperty, которая показывает содержимое ScriptValue и позволяет его редактировать. По умолчанию воспринимает ScriptValue как объект и вытаскивает поля из него. Но может работать с ним и как с массивом.

Пример скрипта из скриншота
test = class test {
    constructor() {
        this.a = 5;
        this.b = 'bla bla bla';
        this.c = false;
        this.d = { a: 5, b: false, c: 'stroka' };
        this.e = new Vec2(3, 50);
        this.f = new Rect(0, 1, 2, 3);
        this.g = new Border(0, 1, 2, 3);
        this.h = new Color4(255, 255, 255, 127);
        this.ref = new o2.RefAnimationAsset('xxx.anim');
        this.ref2= new o2.RefActorAsset();
        this.sprite = new o2.Sprite();
        this.actorRef = new o2.RefActor();
        this.bodyRef = new o2.RefRigidBody();
        this.curve = new o2.Curve();
        this.obj = new o2.EditorTestComponent.TestInside();
        this.array = [ 1, 2, 3, 4, 5 ];
    }

    OnStart() {
        if (this.actorRef.IsValid()) {
            this.actorRef.Get().transform.position = new Vec2(10, 20);
        }
    }

    Update(dt) {
        this.a += dt;
        this.e = new Vec2(Math.sin(this.a), Math.cos(this.a));

        if (this.actorRef.IsValid()) {
            this.actorRef.Get().transform.size = this.e;
        }
    }
}

P.S. Мою имплементацию ни в коем случае не стоит считать эталонной. Эта первая версия, не обкатанная. Она не претендует на идеал решения, но я бы с радостью узнал мнение других.

Теги:
Хабы:
Всего голосов 14: ↑12 и ↓2+10
Комментарии21

Публикации

Истории

Работа

Программист C++
133 вакансии
React разработчик
60 вакансий
QT разработчик
8 вакансий

Ближайшие события