Хотел написать продолжение к статье Что почитать игровому программисту? про использование С++ в игровых движках, но размышления свернули куда-то не туда.

Завороженно смотрю как и какими темпами идет развитие языка в последние годы, и понимаю, что получить и особенно применить возможности С++20/3 в разработке игр и движков получится хорошо, если с опозданием лет эдак в пять, как раз на следующее поколение консолей, если вообще получится. Сейчас плюсы в игрострое зависли где-то между 14 и 17 стандартом, Сони только-только выкатила свою версию компилятора с полной поддержкой 17 стандарта, а учитывая реактивность игровых студий в изменении кор пайплайнов, что-то новое начнут только в новых проектах. Менять коня, т.е. компилятор посреди разработки игры равносильно стрельбе не только по ногам себе, но и соседям программистам: работает - не чини.

Если смена компилятора и стандарта не даст гарантированного прироста скорости работы больше 5%, то бюджет и людей я не одобрю. (с)

Знакомство с кодовой базой больших движков, дает понимание уровня и объёмов кода в продакшене и в тулзах, и ситуация вырисовывается такая, что эти объемы стали в индустрии, что называется "too big to fall", т.е. написать что-то новое, уровня движков вроде Unity/Unreal/Dagor на другом языке, будь он хоть в тысячу раз безопаснее и в десять раз быстрее не получится, но попытки конечно делаются. И чем дальше продолжается поддержка существующих проектов на плюсах, тем меньше возможности выбора остается.

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


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

  • Вендоры платформ (Sony, Microsof, Nintendo) дают API на С/C++, объем кодовой базы их ОС и SDK намного больше, чем игровых движков, использовать что-то альтернативное просто не получится, затраты на переделку похоронят даже нинку с её безразмерными бюджетами.

  • Портирование игр между платформами возможно только на языках C/C++, причину я написал выше, никакого другого общего языка между платформами нет.

  • Компиляторы для плюсов оптимизировались десятилетиями, чтобы получить соответствующую производительность на другом языке, ему тоже придется пройти этот путь, пусть не десятилетия, потому что база уже есть, но годы точно. Писать быстрый платформенный код, относительно высокоуровневый, на чем-то отличном от C/C++ сейчас просто не получится.

  • Legacy - наследованный код, от него никуда не деться, его надо сопровождать и чинить, фиксить баги. А еще надо понимать, что этот код делает и иногда проще написать некоторую часть с нуля, чем дорабатывать то что есть, но не всегда на это есть люди и время.

  • Language pain - англоязычный термин(не нашел к сожалению аналога в русском сообществе) в среде разработчиков игр, который точно описывает почему индустрия не слезет с плюсов ближайшие лет десять точно. Vendor lock обоснован не только применением железа конкретного производителя, но и парадигмой выбранного языка разработки. И у каждого производителя она своя, отдавать даже 1% процент рынка никто не будет, и свой язык программирования только увеличивает присутствие вендора. Учитывая, что потеря одного процента это упущенные десятки млрд убитых енотов, затраты на его разработку даже в 10% от этой прибыли с лихвой окупаются.

  • Шейдеры - выделю эти языки отдельно, хотя они очень близки по своей сути к C, это уже часть платформы без которой игру вы не сделаете. И если в плане общего языка разработки С++ является как-бы общим "философским камнем", способным переплавлять общие идеи в работающий код под любую платформу, то для низкоуровневого высокопроизводительного кода такого общего компонента нет. И скорее всего никогда не будет. Ну просто не получится банально ничего отрисовать на экране. Некоторое время это место занимал OpenGL, но его общими усилиями искоренили почти везде.

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

Hardware/Baremetal/Hardcore C++

Не самый жесткий пример кода
void frustum_for_box_occluder(const TMatrix &to_box_space, const Point3 box_corners[8], const Point3 &eye,
  plane3f out_frustum_planes[BOX_OCCLUDER_PLANES_MAX], int *out_planes_count)
{
  Point3 box_eye = to_box_space * eye;

  G_ASSERT(to_box_space.det() > 0);

  unsigned index = unit_segment_classify(box_eye.x) * 1 + unit_segment_classify(box_eye.y) * 3 + unit_segment_classify(box_eye.z) * 9;
  G_ASSERT(index < 27);

  {
    // Rare case near_box, when the point is located very close to the cube.
    // Then the plane is chosen based on the closest face to the eye.
    bool near_box = likely_inside_m0505(box_eye.x) && likely_inside_m0505(box_eye.y) && likely_inside_m0505(box_eye.z);
    if (near_box)
    {
      float abs_x = fabsf(box_eye.x), abs_y = fabsf(box_eye.y), abs_z = fabsf(box_eye.z);
      int i0 = abs_x < abs_y, i1 = abs_y < abs_z, i2 = abs_z < abs_x;

      float max_coord = box_eye[gComparisonsToMaxCoordIndex[i0][i1][i2]];
      const BoxPointClassificationForOcclusion &cl =
        gBoxPointClassificationForOcclusion[gNearCubeFrontPlaneForOcclusion[i0][i1][i2][max_coord < 0]];

      *out_planes_count = 1;
      Plane3 p(box_corners[cl.mFrontPlane[0]], box_corners[cl.mFrontPlane[1]], box_corners[cl.mFrontPlane[2]]);
      out_frustum_planes[0] = v_ldu(&p.n.x);
      return;
    }
  }

  {
    // Common case. Planes are constructed based on index, obtained from unit_segment_classify for x,y,z.
    const BoxPointClassificationForOcclusion &cl = gBoxPointClassificationForOcclusion[index];
    *out_planes_count = cl.mSidePlanesCount + 1;
    Plane3 p(box_corners[cl.mFrontPlane[0]], box_corners[cl.mFrontPlane[1]], box_corners[cl.mFrontPlane[2]]);
    out_frustum_planes[0] = v_ldu(&p.n.x);
    for (int i = 0; i < cl.mSidePlanesCount; ++i)
    {
      Plane3 p_(Plane3(eye, box_corners[cl.mSidePlanes[i][0]], box_corners[cl.mSidePlanes[i][1]]));
      out_frustum_planes[i + 1] = v_ldu(&p_.n.x);
    }
  }
}

Используется для числодробилок и работе с большими объемами вычислений.

Хорошим примером "такого С++" будут: подсистемы симуляции физики, рендер сцены, коллизии, системы балансирования нагрузки (Tasks/Workers) при использовании в многоядерных системах, анимация персонажей, обсчет воды и частиц (https://github.com/NVIDIA-Omniverse/PhysX)

Или там, где нужно работать с пониманием особенностей платформы (железа) и оперировать такими понятиями как cache locality, branch prediction, упаковка и порядок данных в структурах. Если вы загляните в код этих систем, то выглядеть он будет как написанный на чистом С, с минимальными возможностями плюсов вроде перегрузки функций или наследования. Т.е. тут даже скорости обычных плюсов не хватает, и приходится идти на существенное ограничение возможностей, чтобы выжать еще пару-тройку процентов производительности.

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

Еще сложнее психологически пускать туда малоквалифицированного программиста, в этом случае это не уровень обычного "general" мидла, и скорее всего, даже не уровень синьора. Потому что помимо знаний и представления как эта система работает, на первый план выходит какими средствами и как долго человек эту систему создавал.

В одном из докладов на GDC по Uncharted, разработчики привели замеры, что 80% процентов времени игра проводит в таком коде, и только 20% в общем. Этот низкоуровневый код, быстрее обычного в десятки если не сотни раз, и если скорости мешает архитектура и какие-то правила написания совершенного кода, то и архитектура и правила идут лесом... Перефразирую выражение про капиталиста и 300% прибыли - рендер программист ради 3% прироста, спокойно сломает вам половину редактора, и это будут ваши проблемы, а не его.

Такой низкоуровневый код на недоC++ неидеален, неудобен, пестрит всеми возможными антипаттернами, ходит по грани UB и насыщен персональными трюками отдельных людей, но быстрый и этого достаточно, чтобы его брали в прод. Могут ли другие языки, которые стремятся стать “лучшим С”, т.е. сгенерировать код, что будет работать быстрее, очень и очень большой вопрос. Как раз из-за красивостей, синтаксического сахара, проверок и ограничений такой код теряет до половины скорости работы. Хотите стрелять себе в ногу со скоростью автомата, да пожалуйста. А и забыл еще одно, скорее всего это код скомпилится и заработает на другой платформе.

Плохой пример, не делайте так (знаю про него по рассказам коллег)

В одном из движков "подтекал" стриминг текстур, несильно, хватало на 2-3 часа игры. Починить его не представлялось возможным, потому что этот код был сильно легаси и попытки его починять приводили к статтерам во время игры. В итоге починили вот так, когда игра подходили к границе OOM, просто менялась дата создания файла сохранения на 2039 год, отчего стим воспринимал это как ошибку и показывал системное сообщение. Потом починили нормально, пользователи конечно были недовольны, но списывали это на проблемы сети, стима, компа, но не игры.

Еще одной причиной применения "такого C++", что он позволяет хорошо контролировать производительность получаемого кода там, где это требуется, потому что можно примерно представить во что все эти конструкции скомпилятся в асме.

Middleware/Common С++/Templates

Далее поднимаясь по слоям архитектуры мы попадаем на уровень "обычного" С++. Этот код написан с применением классических "технологий" и алгоритмов, которые изобрели за время развития языка. Здесь располагается 80% кода, который применяется в софте. Сотни библиотек на разных языках, которые в том или ином виде предоставляют доступ через "С интерфейс" к своим возможностям. Различные связки с кор языком ОС, например Java с JNI, Objective C++, виртуальные машины скриптовых языков.

Здесь же язык раскрывается как высокоуровневое средство проектирования, заметьте, не язык написания кода, а именно средство для описания архитектуры приложения (OOD, DOD, DDD). Который позволяет не только выжать все соки из железа, наплевав на все правила хорошего кода, но и показать этот самый хороший код, устойчивый к ошибкам, утечкам, bound check access и защитой от джуна. К сожалению во многих игровых движках здесь еще остались ошметки "ревущих" нулевых, когда плюсы вовсю использовались для написания игровой логики, вы можете заметить это например по доступным исходникам анриала или дагора, где кор логика, связанная с игроком, частично присутствует на самом нижнем уровне объектов.

Ну и конечно язык предоставляет доступ к API библиотек. А при использовании некоторых хаков вроде, privablic access, то и вообще к большей части скрытой от конечного пользователя функциональности. Но если вы думаете, что вот он настоящий С++, то нет, здесь все еще живут призраки "plain C", то там то тут можно увидеть специально упрощенный функционал, чтобы этим уровнем могло пользоваться как можно больше людей.

Выше приведена примерная производительность вычислений в зависимости от используемого уровня технологий, и использование обычного С++ задействует менее 10% возможностей железа, так что неудивительно когда разработчики готовы разменять продуктивность в человекочасах на скорость работы.

Мы с радостью пожертвуем 10% продуктивности ради того, чтобы получить 10% дополнительной производительности”

Tim Sweeney (c)

Если кто забыл как он выглядит

Выливается это в то, что в движок приходят виртуальные машины языков второго и третьего уровня, которые позволяют с одной стороны писать скоростные алгоритмы на уровне движка, а с другой оградить дизайнеров от C++ в пользу чего-то более медленного, удобного и понятного. Сначала это была мода на затаскивание языка скриптов Lua/Js/Squirrel/"Напишите свое", чуть позже пришло время визуального программирования. Скрипты и визуал скрипты (blueprints) это тоже все не изобретение игростроя, они пришли из мира робототехники, где цена ошибки значительно выше и сама ошибка может привести не просто к вылету на рабочий стол, а реальным повреждением оборудования. Минусы такого подхода - то что можно написать в 10 строках кода, займет 1000 строк за счет написания обвязки, проверок, тулов и т.д.

Про снижение производительности и говорить не приходится, даже самая продвинутая Lua VM, как бы не заявляли её разработчики просаживает перф хорошо если только в два раза. Возможно на каких-то синтетических тестах падение производительности составляет десять и меньше процентов, но в реальной игре код из этого теста выполнятся 0.1% времени работы. Это не так критично, как кажется на первый взгляд, потому что все это компенсируется ростом скорости памяти и процессоров, и видеокарт. Но падение производительности оценивается не только в терафлопсах, сам язык луа намного проще чем плюсы. И люди - программисты и дизайнеры, также начинают думать и писать в парадигме упрощенного языка, просто потому что писать сложнее и не надо, да и не всегда получается.

По моему опыту, код переписанный со скриптовых языков обратно на C++, будет быстрее в 5+ раз. Обычно так и происходит, когда по результатам профилирования игры определяются медленные участки. Другие языки скриптов не сильно далеко ушли от Lua, внимание на котором в разработке было акцентировано как минимум лет десять, и за это время его очень прилично ускорили. C момента появления языка в далеком 1993 году, производительность самой виртуальной машины, безотносительно производительности железа, выросла почти в десять раз. На картинке ниже приведены бенчмарки реализаций алгоритмов между разными версиями виртуальных машин языка Lua, красным для примера дано эталонное время работы алгоритма на языке C.

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

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

Еще дальше в этом плане шагнули Unity и Unreal, предоставив возможности визуального скриптования и редактирования объектов и логики прямо во время симуляции, что еще больше снижает требования к базовым знаниям разработки в общем и программированию в частности, так наверное и должны разрабатываться игры, когда ты просто меняешь состояние игры прямо во время игры. Как и в случае с переходом от нативного кода к скриптам, так и от скриптов к визуальному программированию, это еще больше замедляет общий код игры, но дает еще больше защиты от ошибок для команды. Теперь уже скрипты и ВМ выступают в роли фреймворка нижнего уровня, а на уровне визуальных скриптов вы на 95% защищены от возможности скрашить игру, при этом давая доступ ко всему функционалу движка, от шейдеров до анимаций и поведения NPC.

Это однако не гарантирует, что разработка будет легче, я бы сказал наоборот, разработка становится сложнее в общем, но эта сложность размазана между сотнями и тысячами элементов игры. Ну конечно можно факапить похуже и намного быстрее чем в коде. Эта жесть из реального проекта, назовем такую сложность WTF/s(1). Честно - такое никто не будет ревьювать, апрувнут не глядя, молитесь только, чтобы этот ГД довел своего монстра до релиза.

WTF/s (n)
WTF/s (n^2)
Нинада так! WTF/s (80lvl)

Meta/Highlevel C++

Подбираемся к самой мякотке. Кроме обычного плюсового кода, есть еще небольшие части игрового движка, которые требуют использования самых навороченных языковых средств. Это rtti, reflection, compile-time расчеты и средства кодогенерации, когда код игры вырастает из набора конфигов по заданным наборам правил.

RTTI по понятным причинам в 99% случаях выключают, но сама необходимость каста к нужному типу никуда не делась, поэтому почти всегда пишут свою погремушку.

Из-за отсутствия рефлексии в самом языке, её "изобретает" каждая вторая студия - у кого как получится. Готовой и проверенной схемы и технологии рефлексии нет - каждый фреймворк предлагает свои методы разметки кода, сериализации и биндингов.

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

Из известных "хороших" кодогенераторов могу отметить следующие:

  • Cхема данных на отдельном переносимом языке (flatbuffers)

  • Отдельный язык генерации данных и кода для работы с ними (Racket от Naughty Dogs)
    https://www.gdcvault.com/play/211/Adventures-in-Data-Compilation-and
    https://www.youtube.com/watch?v=oSmqbnhHp1c

  • CppHeaderParser - python библиотека из одного файла, которая умеет читать хедеры. Она очень простая, не ходит по #include, пропускает макросы, работает очень быстро и позволяет быстро встроить в пайплайн. 

  • RTTR позволяет создавать и изменять типы, классы, методы и свойства объектов на языке C++ во время выполнения программы. Это может быть полезно для различных целей, таких как сериализация, создание скриптов, генерация пользовательских интерфейсов и многое другое.

Мысли опосля...

Возвращаясь в реальный мир, после просмотра примеров из новых стандартов языка на ютубе или cppcon, когда лямбда обернутая в memfunction, скользит по корутинам, и в очередной раз после бессонной ночи глядя в отладчик и исписанный блокнот с записями обнаруживаю какую-нибудь странную строчку кода, из-за которой непонятно как вообще это всё работало, в сотый раз задумываюсь над тем, что если такое написали в С++11, то как же изощрённо это могут сделать по новому. И как долго потом будут эту багу искать. Игры все-таки пишут с какой-то целью, и просто переписывать код туда-сюда ради рефакторинга, плохая затея. Может и хорошо, что мы живем в своем маленьком С++ мирке, охраняемом святой троицей Sony, Microsoft и Nintendo, которые не пускают сюда драконов из комитета?