// Оператор умножения с присваиванием, для умножения значений с присваиванием
template<typename T, typename X = typename std::enable_if<SupportsMultiply<valueType>::value && std::is_same<T, valueType>::value>::type>
Property& operator*=(const T& value) { _setter(_getter() * value); return *this; }
};
Тут стоит обратить внимание на вот такие вот выкрутасы:
В первой части мы указываем шаблон предполагаемой переменной, с которой хотим взаимодействовать. Ведь могут быть разные типы, например сложения int и float.
Вторая часть - магия type traits (честно одна из самых ненавистных частей С++), которая говорит возможна ли эта математическая переменная между TYPE и T. А
Если вдруг интересно как работает
Уф, насколько же упоротый язык С++... Вот нет бы как-то дать доступ к информации компилятора, но нет, давайте подрочим друг другу
template<typename T, typename X = typename std::enable_if<SupportsMinus<valueType>::value && std::is_same<T, valueType>::value>::type>
В первой части мы указываем шаблон предполагаемой переменной, с которой хотим взаимодействовать. Ведь могут быть разные типы, например сложения int и float.
Вторая часть - магия type traits (честно одна из самых ненавистных частей С++), которая говорит возможна ли эта математическая переменная между TYPE и T. А
std::enable_if здесь врубает SFINAE (вторая самая ненавистная часть С++), которая "отключает" шаблонную функцию из класса, если не удовлетворяется. В данном случае - если между типами невозможна математическая операция, то шаблонный метод перегрузки математической операции не генерируетсяЕсли вдруг интересно как работает
SupportsMinus и тп, велкам в ебанутый код нижеtemplate<class T, class = void_t<>>
struct SupportsMinus : std::false_type {};
template<class T>
struct SupportsMinus<T, void_t<decltype(std::declval<T>() - std::declval<T>())>> : std::true_type {};
Уф, насколько же упоротый язык С++... Вот нет бы как-то дать доступ к информации компилятора, но нет, давайте подрочим друг другу
Окей, вернемся к нашему класс Actor, попробуем в нем определить property:
уфф, немножко громоздко, но уже выполняет свою функцию: transform выглядит как переменная, с ней можно работать в математических операциях (которые позволяет тип Matrix4x4).
Но вещь получается довольно тяжелая из-за использования std::function. Во-первых, у него немаленький размер - 24 байта (х2 в нашем случае), во-вторых куча накладных расходов на инициализацию и вызов
Очевидно, от std::function надо избавляться. И тут очевидное решение - хранить this класса и указатели на setter/getter. Уже значительная экономия, но давайте сразу перейдем к реализации с размером 8 байт😏
Но для этого придется применить настоящую черную магию, которая придется по вкусу не всем... Магия даже скорее такая, коричневая 💩
struct Actor
{
Property<Matrix4x4> transform = Property<Matrix4x4>([this](Matrix4x4 x) { SetTransform(x); }, [this]() { return GetTransform(); }); // А вот и наша property
void SetTransform(const Matrix4x4& mtx) { _transform = mtx; UpdateVisual(); }
Matrix4x4 GetTransform() const { return _transform; }
private:
Matrix4x4 _transform;
};
уфф, немножко громоздко, но уже выполняет свою функцию: transform выглядит как переменная, с ней можно работать в математических операциях (которые позволяет тип Matrix4x4).
Но вещь получается довольно тяжелая из-за использования std::function. Во-первых, у него немаленький размер - 24 байта (х2 в нашем случае), во-вторых куча накладных расходов на инициализацию и вызов
Очевидно, от std::function надо избавляться. И тут очевидное решение - хранить this класса и указатели на setter/getter. Уже значительная экономия, но давайте сразу перейдем к реализации с размером 8 байт😏
Но для этого придется применить настоящую черную магию, которая придется по вкусу не всем... Магия даже скорее такая, коричневая 💩
В общем способ такой: пишем громадный макрос, который принимает в себя тип, имя, сеттер и геттер, и делает две вещи:
- генерит полноценный класс под конкретно это property, который хранить только this и фактически вызывает уже конкретные setter/getter, переданные ему.
- объявляет переменную указанного имени, с типом сгенеренного выше уникального класса
В классе Actor это будет выглядеть вот так:
- генерит полноценный класс под конкретно это property, который хранить только this и фактически вызывает уже конкретные setter/getter, переданные ему.
- объявляет переменную указанного имени, с типом сгенеренного выше уникального класса
В классе Actor это будет выглядеть вот так:
struct Actor
{
PROPERTY(Matrix4x4, transform, SetTransform, GetTransform); // А вот и наша property
void SetTransform(const Matrix4x4& mtx) { _transform = mtx; UpdateVisual(); }
Matrix4x4 GetTransform() const { return _transform; }
private:
Matrix4x4 _transform;
};
Ну а сам монстр-макрос получается такой - https://github.com/o2-engine/o2/blob/014f83b22fe4ef61c53f32bbfc42c0dd3901a348/Framework/Sources/o2/Utils/Property.h#L10-L62
И если со стороны размера такого поля все окей, и с производительностью тоже - фактически накладные расходы минимальны, то вот с распуханием бинарника может быть проблема - мы ведь генерим огромное количество микро-классов под каждую property.
Но здесь отлично справляется оптимизация размера исходника от компилятора. Фактически эти микро-классы крайне похожи друг на друга, и они хорошо сокращаются при оптимизации размера бинарника.
Однако, все же решение далеко не бесплатное. К сожалению, пока я не знаю способа сделать решение с нулевой ценой в С++.
Но для меня это выглядит как неплохой обмен ресурсов на удобство. Тем более, что property в моем движке используются не только для описанных выше целей, но и приходится кстати в рефлексии и редакторе.
Если у вас такое есть, или есть идеи - пишите 🥷
Но здесь отлично справляется оптимизация размера исходника от компилятора. Фактически эти микро-классы крайне похожи друг на друга, и они хорошо сокращаются при оптимизации размера бинарника.
Однако, все же решение далеко не бесплатное. К сожалению, пока я не знаю способа сделать решение с нулевой ценой в С++.
Но для меня это выглядит как неплохой обмен ресурсов на удобство. Тем более, что property в моем движке используются не только для описанных выше целей, но и приходится кстати в рефлексии и редакторе.
Если у вас такое есть, или есть идеи - пишите 🥷
Рубрика ночью накодил.
Вернемся к прошлой теме - C++ properties like C#. Выше я писал что последняя моя реализация все еще хранит один указатель на this, что в памяти 8 байт.
Так вот, как-то @JIukaviy подкинул мне идею пытаться вычислить this по оффсету от самого property. То есть не хранить его, а вычислять. А оффсет считать в компайл-тайме
Таким образом структура проперти получается совершенно пустой, без полей. В С++ она занимает 1 байт, что уже немного покомпактнее.
Вернемся к прошлой теме - C++ properties like C#. Выше я писал что последняя моя реализация все еще хранит один указатель на this, что в памяти 8 байт.
Так вот, как-то @JIukaviy подкинул мне идею пытаться вычислить this по оффсету от самого property. То есть не хранить его, а вычислять. А оффсет считать в компайл-тайме
Таким образом структура проперти получается совершенно пустой, без полей. В С++ она занимает 1 байт, что уже немного покомпактнее.
Из минусов - при работе с property появляется доп операция с арифметикой указателя. Хотя, мне кажется, на перфоманс это вообще не скажется
Из плюсов - пропертя похудели, а их довольно много в проекте получается. На каждый актор, спрайт внушительная пачка. И даже если они небольшие, этих мелочей набегает прилично. А этих спрайтов и акторов бывает десятки тысяч, что превращается в килобайты и мегабайты
Штош, осталось проверить как оно работает на разных платформах. Windows и mac (arm) завелись, так что скорее всего везде будет впорядке
Из плюсов - пропертя похудели, а их довольно много в проекте получается. На каждый актор, спрайт внушительная пачка. И даже если они небольшие, этих мелочей набегает прилично. А этих спрайтов и акторов бывает десятки тысяч, что превращается в килобайты и мегабайты
Штош, осталось проверить как оно работает на разных платформах. Windows и mac (arm) завелись, так что скорее всего везде будет впорядке
This media is not supported in your browser
VIEW IN TELEGRAM
Fast json parsing
Расскажу как в своем движке я реализовал работу с json конфигами. Для этого используется DataValue - это древовидная структура данных, которую можно прочитать или сохранить в файл.
По факту это копия реализации rapidjson, но адаптированная под движок:
- поддержана конвертация в кастомные типы
- сделана связь с рефлексией
- так же в будущем планирую добавить бинарный формат
Расскажу как в своем движке я реализовал работу с json конфигами. Для этого используется DataValue - это древовидная структура данных, которую можно прочитать или сохранить в файл.
По факту это копия реализации rapidjson, но адаптированная под движок:
- поддержана конвертация в кастомные типы
- сделана связь с рефлексией
- так же в будущем планирую добавить бинарный формат
Вот пример его использования:
Т.к. моя реализация на 95% копирует реализацию rapidjson, мне получилось разобраться в некоторых нюансах оптимизации парсинга, о которых я расскажу ниже. А так же немного о том, как он встроен в движок
DataDocument doc; // корневой документ
doc.LoadFromFile("file.json"); // Загрузить из файла
doc["player"]["name"] = "Hero"; // строка
doc["player"]["hp"] = 100; // число
doc["player"]["pos"].Add(12.5f); // массив
doc["player"]["pos"].Add(-3.0f);
// Чтение
const DataValue& p = doc["player"];
int hp = p["hp"].Get<int>();
float x = p["pos"][0].Get<float>();
// Изменение
doc["player"]["hp"] = hp + 25;
// сохранение
doc.Save("save.json");
Т.к. моя реализация на 95% копирует реализацию rapidjson, мне получилось разобраться в некоторых нюансах оптимизации парсинга, о которых я расскажу ниже. А так же немного о том, как он встроен в движок
DataValue
По факту эта структура данных является вариантом значения: null, int, float, double, string, array, object. То есть DataValue может хранить в себе любой из этих типов.
Это уже и есть оптимизация, т.к. хранить различные типы как наследники интерфейса дорого:
В таком виде данные будут раскиданы по памяти, обращение к ним и итерации будут неэффективны, а виртуальные методы внутри будут замедлять работу с даннымм
Вместо этого внутри DataValue реализован union:
Все структуры union: https://github.com/o2-engine/o2/blob/d565330328956f1c2369989192355df0818679ac/Framework/Sources/o2/Utils/Serialization/DataValue.h#L300-L399
Напомню, union в отличие от обычной структуры хранит все данные не последовательно, а в одной области памяти. Выбирается максимальный размер из доступных вариантов, и далее к этой памяти идет обращение к полям в зависимости от того, какой тип предполагается внутри
Рассмотрим с конца -
По факту эта структура данных является вариантом значения: null, int, float, double, string, array, object. То есть DataValue может хранить в себе любой из этих типов.
Это уже и есть оптимизация, т.к. хранить различные типы как наследники интерфейса дорого:
struct IValue { ... };
struct NullValue { ... };
struct IntValue { ... };
...
std::vector<IValue*> valuesВ таком виде данные будут раскиданы по памяти, обращение к ним и итерации будут неэффективны, а виртуальные методы внутри будут замедлять работу с даннымм
Вместо этого внутри DataValue реализован union:
union ValueData
{
IntData intData;
Int64Data int64Data;
DoubleData doubleData;
StringPtrData stringPtrData;
ShortStringData shortStringData;
ObjectData objectData;
ArrayData arrayData;
ValueTypeData flagsData;
};
Все структуры union: https://github.com/o2-engine/o2/blob/d565330328956f1c2369989192355df0818679ac/Framework/Sources/o2/Utils/Serialization/DataValue.h#L300-L399
Напомню, union в отличие от обычной структуры хранит все данные не последовательно, а в одной области памяти. Выбирается максимальный размер из доступных вариантов, и далее к этой памяти идет обращение к полям в зависимости от того, какой тип предполагается внутри
Рассмотрим с конца -
ValueTypeData flagsData. В ней мы храним enum с флагами, какой тип данных хранится в DataValue, с некоторым смещением от началаstruct ValueTypeData
{
Byte padding[DataPayloadSize]; // смещение
Flags flags; // флаги
};
В части
Рассмотрим, например,
В нем мы храним, собственно, double значение и буффер, чтобы выровнять размер каждого типа
С простыми числами все просто, а вот, например, со строками уже интереснее:
Здесь сразу два варианта: обычный поинтер на строку и короткая строка. С поинтером все относительно просто - это указатель на начало строки, но есть интересный трюк с указанием на буффер исходного файла, об этом позже. А вот второй тип - короткая строка - на случай если строка маленькая и вся может влезть в union. Влазит целых 16 символов!
Далее рассмотрим array:
В целом, он тоже простой - держим указатель на первый элемент массива, количество и капасити. Интерес здесь в том, что мы храним по сырому указателю, и нам его не нужно освобождать, т.к. всей памятью DataValue управляет внутренний аллокатор. Об этом тоже чуть позднее
И тип объекта. В нем нужен дополнительный тип DataMember
По сути этот такой же массив, но уже пар ключ-значение
Все это лежит в одном union размером 24 байта
Byte padding[DataPayloadSize] хранятся данные из IntData/Int64Data/DoubleData ... ArrayDataРассмотрим, например,
DoubleData:struct DoubleData
{
double value;
Byte padding[DataPayloadSize - sizeof(double)];
};
В нем мы храним, собственно, double значение и буффер, чтобы выровнять размер каждого типа
С простыми числами все просто, а вот, например, со строками уже интереснее:
struct StringPtrData
{
char* stringPtr;
size_t stringLength;
};
struct ShortStringData
{
static constexpr int maxLength = DataPayloadSize / sizeof(Byte) - 1;
char stringValue[maxLength + 1];
};
Здесь сразу два варианта: обычный поинтер на строку и короткая строка. С поинтером все относительно просто - это указатель на начало строки, но есть интересный трюк с указанием на буффер исходного файла, об этом позже. А вот второй тип - короткая строка - на случай если строка маленькая и вся может влезть в union. Влазит целых 16 символов!
Далее рассмотрим array:
struct ArrayData
{
DataValue* elements;
UInt count;
UInt capacity;
};
В целом, он тоже простой - держим указатель на первый элемент массива, количество и капасити. Интерес здесь в том, что мы храним по сырому указателю, и нам его не нужно освобождать, т.к. всей памятью DataValue управляет внутренний аллокатор. Об этом тоже чуть позднее
И тип объекта. В нем нужен дополнительный тип DataMember
struct DataMember
{
DataValue name;
DataValue value;
};
struct ObjectData
{
DataMember* members;
UInt count;
UInt capacity;
};
По сути этот такой же массив, но уже пар ключ-значение
Все это лежит в одном union размером 24 байта
Кастомный аллокатор
Внутри DataValue используется chunk pool аллокатор, выделяющий блоки по 64кб, и отдающий их на использование DataValue
Аллокатор хранится внутри DataDocument, указатель на который держится внутри DataValue. Таким образом DataValue без DataDocument не может функционировать.
Аллокатор имеет такое же время жизни, как и DataDocument и он умеет только расти - по требованию новой аллокации он отдает поинтер на свободный участок, и если текущий блок закончился, выделяет еще один на 64кб.
Освобождения памяти нет, она линейно растет при использовании DataDocument/DataValue. Это эффективно, т.к. типичный сценарий работы подразумевает либо чтение единого файла, либо формирование структуры и запись. То есть мы много выделяем маленьких кусочков памяти подряд.
Вся память освобождается в момент уничтожения DataDocument, поэтому деаллокаций при использовании chunk pool нет
Комбинация union и собственного аллокатора дают отличную локализацию данных, и как следствие быструю работу с ними
Внутри DataValue используется chunk pool аллокатор, выделяющий блоки по 64кб, и отдающий их на использование DataValue
Аллокатор хранится внутри DataDocument, указатель на который держится внутри DataValue. Таким образом DataValue без DataDocument не может функционировать.
Аллокатор имеет такое же время жизни, как и DataDocument и он умеет только расти - по требованию новой аллокации он отдает поинтер на свободный участок, и если текущий блок закончился, выделяет еще один на 64кб.
Освобождения памяти нет, она линейно растет при использовании DataDocument/DataValue. Это эффективно, т.к. типичный сценарий работы подразумевает либо чтение единого файла, либо формирование структуры и запись. То есть мы много выделяем маленьких кусочков памяти подряд.
Вся память освобождается в момент уничтожения DataDocument, поэтому деаллокаций при использовании chunk pool нет
void ChunkPoolAllocator::Deallocate(void* ptr) {}Комбинация union и собственного аллокатора дают отличную локализацию данных, и как следствие быструю работу с ними
Парсинг
Как и писал выше, я использую готовый парсер rapidjson. Для этого нужно было реализовать интерфейс:
Его использование выглядит так:
В парсинге, кстати, используется тоже локальный кастомный стековый аллокатор, для промежуточного чтения значений и последующего копирования в DataValue
Часть оптимизаций парсера в том, что он работает с исходным входным буффером без копирования. Причем, это может даже использоваться для хранения строк - внутри DataValue::StringPtrData мы храним указатель не на скопированную строку, а указатель на память из исходной строки json, в которой эта строка находится. Таким образом избегаем лишних аллокаций и копирования
Как и писал выше, я использую готовый парсер rapidjson. Для этого нужно было реализовать интерфейс:
class JsonDataDocumentParseHandler
{
public:
DataDocument& document;
StackAllocator stack;
public:
JsonDataDocumentParseHandler(DataDocument& document);
bool Null();
bool Bool(bool value);
bool Int(int value);
bool Uint(unsigned value);
bool Int64(int64_t value);
bool Uint64(uint64_t value);
bool Double(double value);
bool String(const char* str, unsigned length, bool copy);
bool RawNumber(const char* str, unsigned length, bool copy);
bool StartObject();
bool Key(const char* str, unsigned length, bool copy);
bool EndObject(unsigned memberCount);
bool StartArray();
bool EndArray(unsigned elementCount);
};
Его использование выглядит так:
JsonDataDocumentParseHandler handler(document);
rapidjson::Reader reader;
rapidjson::StringStream stream(str);
auto result = reader.Parse(stream, handler);
В парсинге, кстати, используется тоже локальный кастомный стековый аллокатор, для промежуточного чтения значений и последующего копирования в DataValue
Часть оптимизаций парсера в том, что он работает с исходным входным буффером без копирования. Причем, это может даже использоваться для хранения строк - внутри DataValue::StringPtrData мы храним указатель не на скопированную строку, а указатель на память из исходной строки json, в которой эта строка находится. Таким образом избегаем лишних аллокаций и копирования
Интеграция с движком
Сначала я рассматривал использование rapidjson в оригинальном виде, однако стал натыкаться на ограничения. Мне хотелось поддержку простых типов движка, например Vec2F. Чтобы он сразу записывался как { "x": xx, "y": yy }, а так же других кастомных типов
Делается это через конверторы и частичную специализацию:
Вот пример для Vec2I:
Конверторы вызываются из операторов приведения типов и конструктора
Сначала я рассматривал использование rapidjson в оригинальном виде, однако стал натыкаться на ограничения. Мне хотелось поддержку простых типов движка, например Vec2F. Чтобы он сразу записывался как { "x": xx, "y": yy }, а так же других кастомных типов
Делается это через конверторы и частичную специализацию:
// Specialize this template class for your custom serialization types
template<typename _type, typename _enable = void>
struct Converter
{
static constexpr bool isSupported = false;
using __type = typename std::conditional<std::is_same<void, _type>::value, int, _type>::type;
static void Write(const __type& value, DataValue& data) {}
static void Read(__type& value, const DataValue& data) {}
};
Вот пример для Vec2I:
template<>
struct DataValue::Converter<Vec2I>
{
static constexpr bool isSupported = true;
static void Write(const Vec2I& value, DataValue& data)
{
data.AddMember("x") = value.x;
data.AddMember("y") = value.y;
}
static void Read(Vec2I& value, const DataValue& data)
{
value.x = data.GetMember("x");
value.y = data.GetMember("y");
}
};
Конверторы вызываются из операторов приведения типов и конструктора
// Constructor as template value
template<typename _type>
DataValue(const _type& value, DataDocument& document);
// Cast to type operator
template<typename _type>
operator _type() const;
// Assign operator
template<typename _type>
DataValue& operator=(const _type& value);
Так же весь интерфейс сериализации завязан на DataValue, что в целом позволяет использовать довольно локаничный синтаксис:
Для этого реализована частичная специализация конвертера для смарт-поинтеров Ref<T>. В ней записывается/читается тип объекта, таким образом поддерживается полиморфизм при сериализации
// Объявляем структуру
struct MyData: public ISerializable
{
int a; // @SERIALIZABLE
float b; // @SERIALIZABLE
SERIALIZABLE(MyData);
};
// Создаем объект
MyData data;
// Загружаем json
DataDocument doc;
doc.LoadFromFile("data.json");
// Десериализуем данные объекта из данных
data = doc;
Для этого реализована частичная специализация конвертера для смарт-поинтеров Ref<T>. В ней записывается/читается тип объекта, таким образом поддерживается полиморфизм при сериализации
template<typename T>
struct DataValue::Converter<T, typename std::enable_if<IsRef<T>::value && !std::is_const<T>::value &&
!std::is_base_of<ISerializable, T>::value&&
std::is_base_of<o2::IObject, typename ExtractRefType<T>::type>::value>::type>
{
static constexpr bool isSupported = true;
using _ref_type = typename ExtractRefType<T>::type;
static void Write(const T& value, DataValue& data)
{
if (value)
{
data.AddMember("Type").Set(value->GetType().GetName());
data.AddMember("Value").Set(*value);
}
}
static void Read(T& value, const DataValue& data)
{
if (auto typeNode = data.FindMember("Type"))
{
if (auto valueNode = data.FindMember("Value"))
{
String typeName = *typeNode;
auto type = Reflection::GetType(typeName);
if (!type)
{
o2Debug.LogError("Failed to deserialize unknown type: " + typeName);
return;
}
auto sample = type->CreateSampleRef();
value = DynamicCast<_ref_type>(sample);
if (value)
valueNode->Get(*value);
}
}
}
};
Media is too big
VIEW IN TELEGRAM
Рубрика work in progress
Сейчас работаю над редактором анимации, и вот почти доделал функцию записи. С ее помощью можно избежать муторных действий по добавлению анимационных треков. Вместо этого, при включении режима записи, изменения трансформации и любых полей в окне пропертей - анимационный трек будет автоматически создан и добавлен ключ анимации
Очень удобно для быстрого прототипирования анимации с последующим ее доведением, например, через режим редактирования кривых
Сейчас работаю над редактором анимации, и вот почти доделал функцию записи. С ее помощью можно избежать муторных действий по добавлению анимационных треков. Вместо этого, при включении режима записи, изменения трансформации и любых полей в окне пропертей - анимационный трек будет автоматически создан и добавлен ключ анимации
Очень удобно для быстрого прототипирования анимации с последующим ее доведением, например, через режим редактирования кривых
Залипательный youtube-канал: https://www.youtube.com/@AngeTheGreat
Автор делает реалистичную симуляцию двигателя. Реалистичную настолько, что симулируется движение поршня, клапанов, впуска и выпуска. В последних видео автор плотно занимается симуляцией звука выхлопа - просчитывается движение газа, давление, разонанс и так далее
В последнем видео очень интересные результаты: https://www.youtube.com/watch?v=sUdnJTC2w9I
Автор делает реалистичную симуляцию двигателя. Реалистичную настолько, что симулируется движение поршня, клапанов, впуска и выпуска. В последних видео автор плотно занимается симуляцией звука выхлопа - просчитывается движение газа, давление, разонанс и так далее
В последнем видео очень интересные результаты: https://www.youtube.com/watch?v=sUdnJTC2w9I