o2 dev
118 subscribers
56 photos
6 videos
26 files
61 links
About o2 engine development
Download Telegram
    // Оператор умножения с присваиванием, для умножения значений с присваиванием
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; }
};
Тут стоит обратить внимание на вот такие вот выкрутасы:
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:
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 это будет выглядеть вот так:
struct Actor
{
PROPERTY(Matrix4x4, transform, SetTransform, GetTransform); // А вот и наша property

void SetTransform(const Matrix4x4& mtx) { _transform = mtx; UpdateVisual(); }
Matrix4x4 GetTransform() const { return _transform; }

private:
Matrix4x4 _transform;
};
И если со стороны размера такого поля все окей, и с производительностью тоже - фактически накладные расходы минимальны, то вот с распуханием бинарника может быть проблема - мы ведь генерим огромное количество микро-классов под каждую property.

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

Однако, все же решение далеко не бесплатное. К сожалению, пока я не знаю способа сделать решение с нулевой ценой в С++.
Но для меня это выглядит как неплохой обмен ресурсов на удобство. Тем более, что property в моем движке используются не только для описанных выше целей, но и приходится кстати в рефлексии и редакторе.

Если у вас такое есть, или есть идеи - пишите 🥷
Рубрика ночью накодил.
Вернемся к прошлой теме - C++ properties like C#. Выше я писал что последняя моя реализация все еще хранит один указатель на this, что в памяти 8 байт.

Так вот, как-то @JIukaviy подкинул мне идею пытаться вычислить this по оффсету от самого property. То есть не хранить его, а вычислять. А оффсет считать в компайл-тайме

Таким образом структура проперти получается совершенно пустой, без полей. В С++ она занимает 1 байт, что уже немного покомпактнее.
В коде это выглядит весьма просто.
Из минусов - при работе с property появляется доп операция с арифметикой указателя. Хотя, мне кажется, на перфоманс это вообще не скажется

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

Штош, осталось проверить как оно работает на разных платформах. Windows и mac (arm) завелись, так что скорее всего везде будет впорядке
This media is not supported in your browser
VIEW IN TELEGRAM
Fast json parsing

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

По факту это копия реализации 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 может хранить в себе любой из этих типов.

Это уже и есть оптимизация, т.к. хранить различные типы как наследники интерфейса дорого:
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; // флаги
};
В части 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 нет
void ChunkPoolAllocator::Deallocate(void* ptr) {}


Комбинация union и собственного аллокатора дают отличную локализацию данных, и как следствие быструю работу с ними
Парсинг

Как и писал выше, я использую готовый парсер 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 }, а так же других кастомных типов

Делается это через конверторы и частичную специализацию:
// 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, что в целом позволяет использовать довольно локаничный синтаксис:
// Объявляем структуру
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