o2 dev
108 subscribers
49 photos
4 videos
25 files
54 links
About o2 engine development
加入频道
Monosnap Video 2025-06-10 20.1.gif
83.5 MB
В интересное время живем! Когда я смотрел фильмы, фантастику, и там герои смотрели видео и потом такие "а теперь посмотрим с другого угла", я думал - ну и чушь полнейшая... Но вот он сайт - 4dv.ai, и в нем можно сделать именно так. Интересно, станут ли наши видео трехмерными? Или нафиг не нужно?
Поговорим про паттерны
Нет, не будем обсуждать какие есть и какие из них лучше. Этому уже посвящено куча книг и холиваров. А поговорим про их суть, как они работают, и неочевидные места их применения. На мой взгляд первично именно понимание, а применение уже производное: если ты знаешь как это работает, ты лучше понимаешь как это применять или сделать что-то свое.

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

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

Что ж, чтобы понять как правильно их юзать, нужно понять их суть, как они работают. Я бы выделил тут два аспекта:

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

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

Все это артефакты устройства нашего мозга, а точнее его ограниченности. У нас ограниченный контекст и возможность воспринимать новую информацию. Все ведь помнят как в школе/институте под конец дня новые знания превращались в кашу, хотя с утра все шло довольно легко? А еще, небольшой тест: представьте себе 10 отдельных людей, со всеми деталями и движениями. Не получится, мы просто не можем держать такой большой контекст одновременно. Обычно предел где-то около 5-6. Но вот само понятие "10 людей", без детализации, уже довольно простое

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

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

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

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

Будет ли это тонкий или толстый клиент? Это очевидно сразу. Нужно ли выделить здесь интерфейс? Если тебе прямо сейчас это не нужно и ты не на 100% уверен что в ближайшее время понадобится - тебе не нужно выделять этот интерфейс. Какой смысл от интерфейса, если у него единственная реализация?

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

Что будет проще, разбить монолит или собрать пачку классов в кучу, чтобы снова переразбить? Очевидно первый вариант проще, потому что как правило монолит написан проще. Его легче понять, не так жалко выкинуть и просто сделать лучше
Кодстайл.
Внезапно да, какого фига мы тут вроде про паттерны? А дело в том, что кодстайл основывается на тех же принципах и служит тем же целям: эффективность работы команды с кодовой базой

Думая о кодстайле, я вспоминаю свой первый месяц работы в social quantum. Я реально целый месяц привыкал ... к отсутствию кодстайла. Это когда в одном файле часть кода написана по одним правилам оформления, а часть по другим. И все это смешано в лютую кашу. Читать такое было просто физически сложно, но я привык. С тех пор имею способность уметь читать почти любой говнокод. Однако, этот месяц было крайне тяжело

Так и что, в чем сила кодстайла, брат? А все в том же, что и у паттернов - это экономит ресурсы мозга и позволяет легче понимать код, и как следствие работать с ним. Легче работать -> больше профита -> больше денег капиталист зарабатывает.

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

- единообразие. Это касается не только регистра, отступов и пробелов, но и структуры кода. Если в команде договорились сначала писать публичные поля, а затем приватные, что угодно - главное единообразие везде. Всякие нейминги, camelCase/snake_case, отступы и пробелы легко настраиваются линтерами и автоформатированием в IDE. А вот все остально оформление на плечах разработчиков, и тут стоит стремиться "делать как все"

- визуальная компактность и предсказуемость. Нужно время чтобы водить глазами, особенно если не знаешь куда, это отнимает время. А вот если физически расстояния между точками интереса маленькое, и эти точки предсказуемы, мы делаем это быстро. Этому способствует ограничение ширины кода, а точнее ограничение по номеру столбца в тексте. Обычно больше 120 уже получается слишком широко, и на ноутбучном мониторе все становится совсем плохо, ведь приходится прокручивать

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

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

Как и везде, понимание сути ведет к более лучшим результатам, чем тупая зубрежка 😉
This media is not supported in your browser
VIEW IN TELEGRAM
Из насущных будней - работа поля ассета в редакторе.
Оно отображает данные AssetRef<> и взаимодействует с ним. И тут есть интересная особенность - это instance ассеты. Они хранятся не в отдельном файле, а прямо в сцене, а точнее в конкретном акторе и конкретном компоненте

Работа с такой сущностью требует дополнительного функционала: нужно уметь не только "пробрасывать" ссылку на файл и создавать инстанс, но и сохранять инстанс в файл, сбрасывать его

Так же есть кнопка редактирования, чтобы сразу открыть редактор ассета, если таковой есть. Для этого сделан общий интерфейс окна редактора ассета - IAssetEditorWindow. Достаточно использовать этот интерфейс и кнопка редактирования появится автоматически, т.к. такие окна сами себя регистрируют в системе. В нем уже есть дефолтная панель управления ассетом - сохранить, открыть, создать новый и сбросить
o2 dev
Расскажу про свои небольшую разработку внутри playrix, профайл-виджет, показывающий актуальный срез по производительности прямо в игре
——- pew pew ——-
завернул сорцы в отдельный репозиторий. Можно подключить к любому проекту с imgui
https://github.com/zenkovich/imgui_perfmon/tree/main

ps: AI-шка бодро нагенерила коментов в коде 😁
Function<void()> callback;
...
callback += []() { SomethingHappened(); };
callback += []() { SomethingHappenedMoore(); };

Видели такое, мм? )

Те кто писал на C# знают, что это офигенная штука, но в плюсах такого не сделать через +=, придется городить кракозяблы.. Или нет?

Сегодня расскажу про замену std::function в моем движке - o2::Function<>. Он повторяет функционал оригинала из std, но добавляет изрядно синтаксического сахара. Основное - это возможность хранить в коллбеке сразу несколько функций.

А так же немного об оптимизации внутри 😉
Начнем с того, что такое вообще std::function, как оно оборачивает лямду и как это работает.

Вспомним (или узнаем) что лямда - это определенный тип объекта (под каждую лямду отдельный тип), с переопределенной функцией operator(). Собственно когда мы создаем лямду, например вот так:
auto myFunc = [var1, var2]() { ... do something ... }

то мы создаем объект типа этой лямды, в тело которого копируются захваченные переменные var1 и var2, с переопределенной функцией operator(). Этот объект разворачивается примерно в такой код:
struct __my_labmda__
{
int var1, var2;
__my_labmda__(int var1, int var2): var1(var1), var2(var2) {}
void operator() { ... do something ... }
};

__my_lambda__ myFunc(var1, var2);

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

Собственно, std::function<> заворачивает лямду внутри себя. Он хранит в себе экземпляр объекта типа лямды (my_labmda из псевдокода) и так же имеет перегруженную функцию operator(). Вот псевдокод минимальной реализации std::function
template<typename _lambda_type, typename _res_type, typename ... _args>
struct function
{
_lambda_type lambda; // Храним объект лямды

function(_lambda_type&& lambda): lambda(std::forward<_lambda_type>(lambda)) {} // Конструируемся из лямды

_res_type operator(_args ... args) { return lambda(args ...); } // Вызов лямды
};
Из этого псевдокода уже видно, что function может хранить только одну lambda. Вторую и последюущие добавить так просто не получится, тк function уже специализирован под конкретный тип лямды - _lambda_type

Выход простой - завернуть _lambda_type lambda в промежуточный объект с интерфейсом:
template<typename _res_type, typename ... _args>
class IFunction<_res_type(_args ...)>
{
public:
virtual _res_type Invoke(_args ... args) const = 0;
};

Который уже использовать для различных типов лямд - SharedLamda. Так же можно под этот интерфейс завернуть и другие типы функций:
- статичная функция - FunctionPtr
- функция класса - ObjFunctionPtr

Далее function превращается в контейнер объектов от интерфейса IFunction:
template<typename _lambda_type, typename _res_type, typename ... _args>
struct function
{
std::vector<IFunction<_res_type(_args ...)>*> functions; // Храним объекты функций

// Добавляем функцию
function& operator+(IFunction<_res_type(_args ...)>* func)
{
functions.push_back(func);
}

// Вызываем все функции, возвращаемое значение берем от последней
_res_type operator(_args ... args)
{
if (functions.size() == 0)
return _res_type();

for (int i = 0; i < functions.size() - 1; i++)
functions[i]->Invoke(args ...);

return functions.back()->Invoke(args ...);
}
};

Этот всевдокод уже показывает как нам сохранить несколько функций в одной. В реальном Function<> богатый api для работы с разными фнукциями и лямдами сразу. Они конструируют нужную реализацию IFunction внутри и добавляют в список

Но что насчет перфоманса? Тут и аллокации, и поинтеры? Моя первая реализация и правда была настолько простой, как в псевдокоде. Но это давало ощутимые просадки, ведь даже на передачу одной лямды требуется работа с вектором, а это и лишняя память, и аллокации...
Здесь я применил довольно простую оптимизацию, наподобие small string optimization. Суть ее простая - если один объект по размеру меньше или равен контейнеру, в который он помещается, то он хранится прямо в области памяти контейнера

Чуть подробнее. Для этого используется Union - это такая штука позволяющая в одном участке памяти как бы хранить несколько типов данных сразу. Все сразу использовать нельзя, но эту память можно интерпретировать как один из этих типов. Например:
union data
{
float number;
std::string string;
bool flag;
};

data d = ...;

d.number = 5; // запись данных из области памяти d в виде float
d.string = "hello"' // запись данных из области памяти d в виде строки
d.flag = true; // запись данных из области памяти d в виде boolean

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

Для более простой работы используется современный std::variant, однако union'ы нам нужны для понимания оптимизации

Ведь в этом union хранится сразу два варианта хранения функций внутри: одна функция или множество
Вот вырезка из кода, в каком виде это хранится:
struct TypeData
{
Byte padding[payloadSize];
DataType type;
};

struct OneFunctionData
{
static constexpr UInt capacity = payloadSize - sizeof(void*);

Byte functionData[capacity];
void(*destructor)(IFunction<_res_type(_args ...)>*) = nullptr;
};

union Data
{
std::vector<IFunction<_res_type(_args ...)>*> functions;

OneFunctionData oneFunctionData;

TypeData typeData;
};

Рассмотрим по порядку:
- TypeData - контейнер типа хранимых данных. Типа хранится позади padding[payloadSize]
- OneFunctionData - контейнер для одной функции. Внутри:
- кусок памяти под саму IFunction - Byte functionData[capacity],
- указатель на деструктор этой функции. Он необходим для корректного освобождения хранимой функции. Ведь в ней могут быть захвачены переменные, которые необходимо корректно освободить и тп
- std::vector<IFunction<_res_type(_args ...)>*> functions - собственно вектор с объектами функций

Когда Function<> конструируется из одной IFunction<>, например небольшой лямды или указателя на функцию класса, то включается оптимизация и эта функция без аллокации сохраняется прямо в functionData[capacity].

Если она больше, или функций больше одной, то переключаемся на вариант с std::vector<>

Это работает довольно хорошо, потому что в большинстве случаев мы все-таки в Function<> храним только одну функцию. Чуть затратнее по памяти, однако косты на аллокации срезаются. У меня разница в производительности была заметна на глаз - этап загрузки редактора с кучей коллбеков ускорился значительно. Лишний раз подтвердило что аллокации - зло
И, напоследок, небольшой бонус. Как это вообще так определяется шаблонный класс, умеющий принимает в себя аргументы шаблонов в специфичном виде? ))
Function<_res_type(_args ...)>


Просто так объявить такой шаблон не получится... Все дело в магии forward'а этого класса. Если объявить его в таком простом виде, то затем можно сказать компилятору принимать шаблоны в этом специфичном виде
template <typename UnusedType>
class IFunction;

template<typename _res_type, typename ... _args>
class IFunction<_res_type(_args ...)>
{ ... };


Полный исходник можно посмотреть здесь: https://github.com/o2-engine/o2/blob/master/Framework/Sources/o2/Utils/Function/Function.h
This media is not supported in your browser
VIEW IN TELEGRAM
навайбкодил. Питон, ни одной строчки руками не набрал. Но я знаю как должно работать внутри, все по моей инструкции. Страшно 🥲 или нет?
штош, что в итоге навайбкодил. Кстати, использовал новую-свежу модель GPT-5. Могу поделиться впечатлениями, они довольно неоднозначны
CodeGraphViewerTool.zip
166 KB
тулзу на питоне сгенерировал почти без ручных правок кода. Сгенеренный код довольно хорош. В целом llm с питоном и раньше справлялся лучше чем с С++, но тут я бы сам отревьюил код как "хороший"

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

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

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

и если тестовый пример с десятью сущностями работали прекрасно, то на 10 тысячах стало все прям печально.

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

плохая новость в том, что кадр все равно обсчитывает очень долго - полторы секунды - и тулза совсем бесполезна

поэтому я пошел на следующую итерацию, сделать тулзу на С++
и здесь я сразу решил с ноги зайти и просто написать что за тулзу я хочу. Да да, одним промтом, вот прям из коробки.
За основу я взял демо-проект imgui со своим прикрученным профайлером

Вот первый промт:
я хочу сделать графическую утилиту, которая сканирует исходники С++ в нескольких папках, строит зависимости, и отображает с помощью imgui в графическом виде эти зависимости. Графически эти зависимости кластеризуют связанные сущности (исходники), с помощью физических законов. Сущности представлены в виде цветных кружков, зависимости (или связи) в виде линий.

Зависимости строятся на 2х уровнях:
- иерархия папок и подпапок: для каждой директории создается сущность директории, дочерняя директория зависит от родительской. Внутри директории есть список сущностей - файлов в ней
- зависимости между исходниками С++ через #include: из каждого исходника парсится список инклюдов, ищутся файлы-исходники по путям и между ними создается связь

Работа физики:
- для этого сущности расположены в 2д мире, у каждого есть позиция
- между ними есть зависимости- связи, которые задают некое расстояние между сущностями
- сущности стараются оттолкнуться от билзлижащих сущностей
- используется интеграция верле и разрешение связей как в научной работе якобсена из hitman codename 47
- при зажимании левой кнопки мыши умеет захватывать сущность чтобы ее перемещать вслед за курсором

Графика:
- используется imgui Для отображения. В текущем исходнике Main.cpp создается дополнительное окно, в котором отображается вся графика: сущности и связи
- сущности - это разноцветные круги
- связи - это линнии между ними
- существует камера, которой можно управлять: скролл (перемещение) с зажатой правой кнопкой мыши, зум с помощью колесика
- наведение курсора на сущность включает ее подсветку: отображается имя исходника, а так же связь с родителем и детьми
- для профилирования используется nano profiler: работа физики и графики

что нужно сделать:
- завести json конфиг с параметрами: исходные директории для сканирования, настройки графики и физики. Работает как отдельный класс
- сделать класс парсера исходников с многопоточностью: берет исходные директории из json конфига и рекурсивно сканирует их в поисках C++ исходников .h/.cpp, парсит #include, связывает сущности, создает сущности для всех директорий
- физический движок, оптимизированный для вычисления 10000 сущностей и 30000 связей. Инициализируется из парсера исходников, сохраняя ссылки на исходные сущности. ПРоводит симуляцию мира и физики: интегрирует позиции, разрешает связи, расталкивает близлижащие сущности
- графическое отображение. Содержит камеру, обрабатывает инпут пользователя, отображает сущности и связи
- главный класс утилиты: читает конфиг, парсит исходники, инициализирует физику и графику, запускает игровой цикл апдейта фрейма - обонвление физики, обработка инпута и отрисовка

Разделяй вышеописанное на классы в разных файлах, рядом с Main.cpp. Используй для всего стандартную библиотеку stl и imgui. Не используй ничего кастомного, никаких скриптов, только С++