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

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

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

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

Но это довольно сложно реализовать по всем фронтам - нужно разбираться с физикой воздуха, бить на сектора, понимать какая часть самолета в каком кубе и тд. При этом секторов будет овердофига, тк мы в 3х измерениях. Если разбить на 1000 по высоте/ширине/длине, то получается миллиард ячеек. Поди посчитай их 60 раз в секунду, для одного самолета. А их может быть несколько... В общем это зубодробильный подход и вряд ли он применим в играх (разве что во flight simulator)
Попробуем упростить и декомпозировать задачу. Разобьем самолет на куски:
- винт впереди, он тупо тянет
- фюзеляж
- крылья
- хвост
- всякие элероны, закрылки и тп, в общем всякие управляющие штуки

И попробуем применить вышеописанную симуляцию для каждой отдельной части в предрассчитанном виде. То есть, можно задать определенные графики зависимости скорости воздуха относительно каждой оси объекта (XYZ), к силе, которая оказывает влияние на эту часть. Какие это графики будут?

Возьмем за пример крыло. Попробуем представить какие это графики были бы. Еще раз - по каждой оси строим график силы от скорости воздуха вдоль оси. Ось X располагается вдоль крыла, ось Z перпендикулярно в плоскости крыла "вперед", ось Y перпендикулярна крылу и направлена вверх.
- по оси X: ветер почти не влияет на крыло, сила маленькая
- по оси Y: ветер влияет сильно, чем сильнее ветер, тем сильнее сила. Вероятно график не лениейны
- по оси Z: как и по оси X почти не влияет
Можем дополнительно сделать графики так же для момента силы, то есть закручивания. По идее крыло еще немножко подкручивает при сильном ветре по оси Z.

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

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

Отдельно обсчитываем элементы управления. Можно строить графики сил для их полного открытия и умножать на коэффициент от 0 до 1 в зависимости от управления этим элементом. Ну или просто прикладывать какую-то очевидную силу с тем же коэффцициентом.

Винт, или двигатель, работает супер просто - в зависимости от "газа", прикладываем соответствующуюю силу по вектору тяги
В общем-то все, этого было достаточно сделать для более-менее правдоподобной физики самолета. К сожалению, демка пропала, видео осталось короткое, а делать с нуля не интересно уже
Замечательную херь в С++ выявил. Связана она с порядком инклюдов и частичной специализацией шаблонных классов. Ну как херь, это поведение by design, но я считаю лютая херь!

В общем, в чем суть. Я сейчас перевожу движок с сырых указателей на умные, и под это дело пишу свои указатели. Зачем - отдельный вопрос, вкратце ответ как всегда - для бОльшего контроля. По-сути умный указатель - это шаблонный класс: Ref<T>; (да, еще и ссылками обозвал, ну да не суть).

Есть в движке особенность - это ссылки на другие акторы, компоненты и ассеты. Они, по сути, те же смарт поинтеры, но немного сложнее: они умеют сериализоваться, скриптоваться и другие полезные штуки. Меня посетила кристально чистая идея - "а чего бы не специализовать смарт-поинтеры под определенные типы, добавив им нужной функциональности??"

Получается довольно красиво: любая ссылка в движке - это Ref<T>, который ведет себя немного по-разному, в зависимости от того чем Т является. Однако, это факап...

Да да, тупая фича С++, из-за которой он компилится миллоны лет, то что придумали тогда когда я еще не жил на свете - это инклюды. Честно говоря такой херни я не видел ни в одном языке... Однако, это дерьмо мамонтов живет и в наши дни (прям как cmake, хех), несмотря на то что это просто НИКОМУ не нужно.

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

Черт, я понимаю почему так получилось очень давно. Но почему это до сих пор существует - не понимаю. Точнее, я знаю, это из-за обратной совместимости. Но, камон, какая версия из стандарта была по-настоящему обратно совместимой? Любой, кто мигрировал проекты на новый стандарт знает, что это банально не правда.

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

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

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

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

По-моему это дичь. Во-первых, все эти неявные действия - зло. Ибо ведет к путанице. Во-вторых, кому сегодня нужны эти инклюды? Почему не парсить все хидеры сразу и компилить с общим глобальным пространством имен?
Написал статью на хабр: https://habr.com/ru/articles/811369/

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

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

Однако плюсов оказалось больше, что все таки значит что тема уродства С++ больная для сообществе. Забавно что плюсов оказалось больше, чем у других моих статей про физику или систему UI в o2
Закончил рефакторинг, длившийся пол года. Это перевод движка с сырых указателей на умные. Казалось бы это 5 минут туда и обратно, но оказалось что лишь казалось
В целом все довольно скучно и рутинно, но особенность в том что я написал свои собственные умные указатели. В процессе разобрался в особенностях умных указателей, попробовал разные подходы, о чем и хотел бы поведать.

Для начала, почему я стал писать свои собственные? Тут пара причин.

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

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

Итак, начнем с концепции. Я взял концепцию сильных и слабых ссылок (shared/weak_ptr), с внешним счетчиком, но в одном блоке памяти с объектом. Сильные и слабые ссылки позволяют относительно удобно управлять владением объекта, это довольно понятная и распространенная концепция.

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

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

Так же я решил немного изменить нейминг и сделать название короче, назвав сильную ссылку Ref<>, а слабую WeakRef<>. По ссылкам можно найти сорцы

Далее, моя большая архитектурная ошибка. Дело в том, что у меня в движке, наподобие Unity3D, компоненты на сцене могут хранить ссылки на другие компоненты или акторы. Эти ссылки - по сути указатели, но с дополнительным функционалом. Например, у нас есть прототип объекта (или префаб), и в нем есть ссылки внутри, то при копировании прототипа ссылки должны иметь такую же структуру, но уже в новом объекте. А так же нужно уметь записывать эти ссылки в файл и восстанавливать при загрузке.

У меня сходу возникла идея использовать специализацию для ссылок на Actor/Component/Asset, с добавлением нужного функционала. То есть для обычных объектов используется дефолтный шаблонный класс Ref<>, а для вышеперечисленных специальные частичные специализации, то есть другие Ref<>-классы, с особым функционалом. Таким образом в синтаксисе всегда используется единая простая концепция с Ref<>/WeakRef<>.. А под капотом уже разруливается нужное поведение.

Немного о частичной специализации, как ее делать. Допустим, у вас есть класс шаблонный MyClass<T>, и вы хотите чтобы если T - это наследник от MyBase, то MyClass<T> вел себя как-то иначе. Для этого создается дополнительный "сервисный" параметр шаблона, который в оригинальном классе по-умолчанию равен void:
template<typename T, typename E = void> MyClass {};
Чтобы задать частичную специализацию, мы можем сделать другое определение MyClass:
template<typename B>
class MyClass<B, typename std::enable_if<std::is_base_of<MyBase, B>::value>::type>
{};


Здесь переопределяется параметр E на вот эту кракозяблу, которая становится void если выполняется условие, что тип B наследуется от MyBase.
В общем, эта классная идея не выгорела даже на этапе компиляции. Проблема в С++, в концепции инклюдов и forward-declaration'ах. Ему становится плохо от таких специализаций...

Дело в том, что если в .cpp не будет подключен .h, содержащий вашу частичную специализацию, то компилятор о ней и не узнает, и просто возьмет не специализированный шаблонный класс, и сделает его инстанс. То есть Ref<Actor> заработает как обычный указатель, и не будет уметь сериализоваться/делать нужные штуки. Проблем добавляет цикличность зависимостей - class Actor уже хочет знать о существовании специализации Ref<Actor>, и приходится хитро форвардить. А так как в самом class Actor тоже есть шаблонные методы, например Ref<T> FindActorByType<T>(), то просто зафорвардить не получится, т.к. реализация функции в том же .h где и форвард, и компилятор ругается на отсутствие реализации Ref<T>, который определен только forward'ом. Это решаемо, но через уродливые #include "" посреди .h, что выглядит как минимум странно

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

Еще сильнее колено отстреливается когда в .h нет нужной специализации, а в .cpp она появляется. Здесь происходит откровенный пиздец, т.к. компилятор думает что класс определен с Ref<> одного размера (8 байт на указатель), а реализацию функций с другим` Ref<>` (8 байт на указатель + 8 байт на вспомогательную инфу). В рантайме это выглядит как запись вне стека/участка памяти и шваркается рандомно. Слава господи address sanitizer сразу подсвечивает такие проблемы. Хотя разобраться в таком креше - это капец, абсолютно не понимаешь почему у тебя программа пытается писать за пределами объекта в конструкторе.

Об этом писал в своей статье в предыдущем посте, что такой проблемы в принципе бы не существовало, будь у С++ другая стратегия компиляции, без инклюдов и форвардов.
Уже после проблем с компилятором, смирившись что со специализациями не получается, я стал думать о классах с соответствующим назначением: ActorRef<>, ComponentRef<>, AssetRe<>.

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

В итоге я пришел к более простому и правильному решению - выделить ссылки на акторы/компоненты/ассеты в отдельные классы, отвечающие именно за эту функциональность: LinkRef<>, специализирующиеся под Actor/Component, и AssetRef<> для ссылок на ассеты
Далее, были изощрения с самим классом умного указателя. Как я в начале писал, у меня используется внешний счетчик, но в одном блоке памяти с объектом. То есть аллоцируя объект, выделяется чуть больше памяти и перед объектом кладется счетчик. Это улучшает работу с кешем процессора, т.к. счетчик обращаясь к объекту скорее всего будет взаимодействие со счетчиком. При обращении к счетчику мы сразу возьмем весь объект или его кусок в кеш, и его не нужно ждать из памяти, как в случае если счетчик и объект не рядом.

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

Работает это все через функцию-конструктор, вместо обычного вызова new. То есть создание объекта получается вот так:
auto myObject = mmake<MyClass>(...);.

Такое оборачивание нужно для того, чтобы перегрузить поведение выделения участка памяти и добавить инициализацию счетчика:
- аллоцируем участок памяти размером sizeof(RefCounter) + sizeof(MyClass)
- вызываем placement new для счетчика
- вызываем placement new для объекта
- передать счетчик ссылок в объект

placement new - это конструирование объекта, наподобие обычному new, но без аллокации, а с уже указанным участком памяти

Здесь сразу появляется пачка проблем. Начиная с банального удобства, ведь когда пишешь mmake<MyClass> в IDE, она не знает что тебе нужно показать список аргументов конструктора, для него это просто какая-то шаблонная функция с вариативным кол-вом аргументов. Соответственно, нужно просто помнить список аргументов

Далее проблема использования сильных и слабых ссылок в конструкторе класса. Например, конструируя Actor, внутри создается так же и ActorTransform, который хочет держать слабую ссылку на сам Actor. Или в конструкторе копирования, нужно продублировать всех детей, которые так же хотят иметь слабую ссылку на родителя. В целом есть еще кейсы, когда в конструкторе объект уже хочет взять сильную или слабую ссылку на себя же. Но так сделать не получится, потому что счетчика еще нет у объекта

Это заставляет искать обходные пути, например писать фабрики для объектов. Для копирования делать функции а-ля Clone(), с раздельным конструированием и прокидыванием ссылок внутри. Я попробовал несколько альтернативных подходов

1. использовать фабрики, как описано выше. Просто не удобно, нужно знать о фабриках, получается плохой и не очевидный синтаксис
2. использовать методы пост-инициализации, в которых прокидывать ссылки в детей и т.п. Это нарушает концепцию RAII, которая гласит что объект готов к использованию сразу после конструктора
3. как-то хитро закинуть счетчик ссылок в конструктор, через вторичное глобальное хранилище. То есть объявляем глобальную переменную на тред, в которую кладем счетчик, откуда уже его можно взять в конструкторе. Убивает кеш процессора
4. закидывать счетчик ссылок прямо в конструктор параметром. Придется тащить в параметры конструктора сервисную переменную, а так же во всех наследников этого класса
К себе я взял 2 и 4 пункты. 1й может реализовать сам юзер. 3й вредный для производительности. Со 2м оказалось не удобно, т.к. конструктор разбивался фактически на две функции. Писать алгоритмы в таком ключе не просто, а тем более переписывать уже готовые конструкторы, где есть расчет что ссылку можно взять сразу (с сырыми указателями такой проблемы ведь не было). А вот с 4м получилось все более-менее хорошо: алгоритмы просты, т.к. можно сразу работать со ссылками на себя, но приходится везде протаскивать RefCounter* refCounter в параметре конструктора, в т.ч. по всех наследниках. Протаскивание довольно неприятно, ведь это по дефолту уходит в наследников компонент и акторов, но все-таки терпимо и делается быстро.

А вот с тем, как закинуть это в конструктор - отдельная магия. Для этого функция mmake<T> умеет понимать, принимает ли конструктор T() первым параметром RefCounter* refCounter. Если да, то созданный ранее счетчик передается первым параметром в конструктор. Далее идут параметры, переданные в mmake<>(...). Там же в mmake<>() делается проверка на наличие метода PostRefConstruct() в конструируемом типе, и если есть - вызывается.

Так же сама функция mmake<>() собственно не функция, а хитрый макрос, который умеет собирать место аллокации в сорцах для последующего анализа аллокаций. То есть создавая объект через mmake<>(), мы получаем запись о том что в таком-то .cpp в такой-то строке был создан объект размером Х байт. Далее можно просто смотреть откуда в сорцах аллоцируется больше всего. Работает это через прослойку с маленьким объектом на стеке, который запоминает путь к .cpp и строку через макросы __FILE и LINE__.

Собственно вот так выглядит комбайн по созданию умных указателей. Внутри он делает все что нужно, на выходе выдавая готовый Ref<> с вызовом всего что определено пользователем.
Отдельный пункт про наследование. Так уж выходит, что базовый класс для объектов со счетчиком - RefCounterable - начинает пересекаться при множественном наследовании.

Например, есть class ISceneDrawable: public RefCounterable
и есть class CursorEventsListener: public RefCounterable
и ты пытаешься сделать class MyClass: public ISceneDrawable, public CursorEventsListener.

Делать разные счетчики, очевидно не правильно. Можно попробовать применить виртуальное наследование, но тогда все становится хуже, причем весьма неочевидным способом. Дело в том что компилятор перестанет вообще вызывает конструктор RefCounterable(refCounter) у наследников, даже если это явно прописано, а будет вызывать просто RefCounterable(). Такая вот редиска при множественном виртуальном наследовании.. Чтобы вызывался RefCounterable(refCounter), нужно его вызывать из всех классов. Что не удобно, не красиво и легко забыть, получив очередную пулю в колено.

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

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

Теперь интересно будет вкрутить тот самый GC в умные указатели, с которого и проросла идея своих смарт-поинтеров. Это будет в виде ужасных макосов, вкоряченных в Ref<>, а так же потребуется как-то разметить "рутовые" ссылки, от которых начнется построение дерева памяти. Но эту штуку я уже давным-давно делал и даже писал статью на хабр.
Еще довольно быстро прикрутил к проекту профилировщик tracy. Он активно используется на работе в Playrix, и весьма удобен для геймдева. Обычно профайлеры показывают статистику за весь промежуток работы приложения, что для игр не удобно. В играх гораздо лучше рассматривать время кадра, статистику между кадрами. Отделять разные этапы загрузки игры. Tracy позволяет все это увидеть, разметить все как тебе нужно. А еще он подсоединяется по сети, и можно профайлить прямо с реального девайса. Это весьма удобно

Интеграция очень простая. Подключаешь сабмодулем, добавляешь пару сорцов или подключаешь cmake, делаешь минимальную разметку и готово. У меня ушло пару часов чтобы подключить tracy к проекту.

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

Для интереса - дамп tracy. Его можно открыть клиентом tracy с версии 0.1.0