Spiiin's blog

С++ в геймдеве

Немного о том, почему в геймдеве используется C++, как именно он используется, и всё ли с этим правильно.

Почему

Тут всё просто — потому что:

  • Вендоры платформ предлагают некоторый готовый API на уже выбранном языке, использовать что-то альтернативное — дополнительные затраты
  • Игры часто портируют на различные платформы, C (или подмножество C++) - это подмножество, которое можно использовать, чтобы переписывать минимальное количество кода
  • Компиляторы C оптимизировались годами, на нём можно писать быстрый код, и использовать его как подмножество С++, там где нужна скорость

Как используется

Я бы выделил в типичной игре, написанной на C++, три “стиля” кода - низкоуровневый, стандартный, и высокоуровневый.

(Тим Суини в презентации The Next Mainstream Programming Language в презентации 2010 года, выделяет в отдельную группу так же код шейдеров на HLSL/GLSL, но это уже не C++).

Низкоуровневый C++

Используется там, где нужны какие-либо быстрые Numeric Computation - вычисления с большими объёмами данных. Примеры задач:

  • Обход графа сцены
  • Симуляция физики, определений столкновений
  • Легковесная система задач для получения выгоды от использования нескольких процессоров
  • Системы анимации 3D персонажей
  • Куллинг, отправка данных на отрисовку
  • Системы частиц
  • Использование особенностей железа - повышение cache locality, пакинг данных в структуры
  • Тяжёлые алгоритмы рендеринга (частично решаются не на C++)

Часто (но не всегда) является “инлайнингом си”, неотделимым от обычного кода. Это бывает как удобным (практически тот же синтаксис), так и не очень — сложнее не допустить в такой код низкоквалифицированного программиста, и почти невозможно запретить использование отдельных синтаксических возможностей языка.

Суини на примере Gear of War приводит такое разделение:
LoC - 50%/50% - половина кода на “обычном c++” (стандартный + высокоуровневый по моему разделению), половина на низкоуровневом. Для менее требовательных к ресурсам игр (типа мобильных казуалок), я бы привёл цифру в 10-20% на низкоуровевый код.
Использование CPU - 10% времени процессор выполняет код написанный на обычном уровне (игровая логика + скрипты), 90% - на требовательные к скорости рассчёты.
Производительность кода - низкоуровневый код в среднем в 10 раз быстрее стандартного.

Код на C неидеален, но удобен в том плане, что не нужно переключаться на другой синтаксис или явно инлайнить его какой-либо директивой. Поэтому другие языки, которые стремятся стать “лучшим С”, иногда поддерживают транспиляцию в C — сгенерированный C код может быть быстрее, чем средний код, написанный человеком (так же, как компилятор может сгенерировать более быстрый машинный код, чем средний программист написать на ассемблере), и можно получить переносимость на все платформы, для которых есть компилятор C.

Минусы других языков в том, что они 1) не “инлайнятся” в C++-код 2) требуют особой обработки для фич, недоступных в C++, чтобы взаимодействовать с ним (что-то вроде отметок unsafe для такого кода). Ну и не имеют столь тесной интеграции с C++, низкоуровневый код — это не обязательно исключительно код на C, можно сочетать “С с элементами C++”/“С++ с элементами С” в любых пропорциях. Быстрый низкоуровневый код получается в случаях, когда программист более-менее представляет, во что он скомпилируется.

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

Стандартный C++

Это код, написанный “по заветам классиков”. Задачи, решаемые на “стандартном” уровне:

  • “Связка” используемых библиотек и языков: ~10-20 middleware библиотек, связка с хост-языком операционной системы (Java с JNI, Objective C++ с “инлайном С++” для мобилок), вызовы скриптовых языков, для которых хостом является игра
  • Высокоуровневая архитектура приложения (Object Oriented Design, Data Oriented Design, паттерны проектирования)
  • Написание кода, устойчивого к ошибкам (утечки памяти, использование освобожденной памяти, разыменование кривых указателей, неинициализированные переменные, выходы за границы массивов и прочее прелести)
  • Описание игровой логики
  • Вызов кода API библиотек, предоставляющих интерфейс на этом языке
    Иногда открывающих доступ к большей части скрытой производительности железа:
    Language Delay - чистый C++ позволяет использовать 0.25% производительности компьютера (объяснение, где скрыты остальные 99.75% на видео).
    desktop-power

Описание игровой логики

На этом уровне Суини выделяет такой момент:

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

(т.е. фактически, чтобы защититься от стандартных ошибок, которые позволяет совершить язык).

Распространённый вариант такого обмена — это использование каких-либо других языков, т.е. отказ от C++ в пользу чего-то более медленного, но устойчивого к ошибкам.

Традиционный для геймдева вариант — Lua. Во многом, идея такой привязки навеяна использованием языков сценариев. Минусы — производительность снижается далеко не на 10% (что компенсируется ростом производительности железа), и то, что некоторые программисты страдают от искусственного упрощения языка в целях “защиты от дурака”. По опыту, код на Lua, переписанный на C++, ускоряется раз в 5-10. Другие традиционно используемые для скриптования языки, не особо отличаются от Lua ни в плане производительности, ни в плане предоставляемых возможностей.

Ещё один минус — необходимость создания привязок C++ объектов для возможности использования их в скриптовом языке. Это задача требует использования “высокоуровневого” C++, и её особенности будут описаны в следующем разделе.

Плюсы — доступ к программированию непрограммистов (хотя их код в плане производительности теряет ещё больше, количество WTF в единицу времени в коде гейм-дизайнера зашкаливает).

Но главный плюс, ради которого часто можно пожертвовать производительностью, это возможность изменить скрипт без перезапуска программы. Это не получается автоматом, а требует некоторой настройки работы с языком, зато в некоторых случаях позволяет повысить производительность программиста не на 10%, а на порядок (в 10 раз). Компиляция, перезапуск игры и прохождение до нужного места с воспроизведением ситуации займёт около минуты, перезагрузка скрипта может выполниться за несколько секунд.

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

Серьёзный шаг вперёд в направлении использования скриптовых языков сделала Unity, предоставляя программисту в качестве языка для скриптов C# — со статической типизацией (также пробовали Boo и JavaScript, но забили), выразительнее минималистичных “подрезанных” соперников, потенциально быстрый, с возможностью рефлексии, компилируемый в C++ с помощью ill2cpp, и с богатой стандартной библиотекой.

Небольшую проблему представляет запрет на использование виртуальных машин на платформах типа Apple, что обходится “нативизацией” скриптов в C++ код (AoT-компиляция). Таким образом, на устройстве игрока будет присутствовать только нативный код, а на машинах разработчиков — текстовый.

(Честно говоря, не очень понимаю смысл этого запрета, многие современные игры позволяют изменять поведение игры за счёт использования данных, полученных с серверов разработчика — вполне допускается загрузка, к примеру, нового главного меню с кнопкой, которая вызывает код, недоступный для проверки вендору. В одной китайской стратегии я находил использование чата на JavaScript - в котором разработчики теоретически могут хоть запустить игру в игре).

Визуальный скриптинг
Кроме текстовых языков применяется визуальный скриптинг, который ещё больше снижает когнитивную нагрузку при написании кода, и делает более сложным написание тормознутого или неработающего кода для непрограммистов. Как примеры Blueprint classes в Unreal, или VSO от Playrix.

Из плюсов — такой язык почти не требует изучения и почти не подвержен ошибкам, по сути, позволяет создавать различные языки для решения различных задач, кроме описания игровой логики.
В Unreal с помощью визуального программирования можно описывать:

  • Макросы конструирования объектов в редакторе (в том числе параметризированные), вообще не попадающие в игру
  • Материалы
  • Конечные автоматы поведения персонажей
  • Системы анимации

Подготовка игрового контента чаще всего рассматривается как отдельная задача, не связанная с игрой, однако в некоторых случаях бывает полезна генерация С++ кода для работы с этими данными — задачу генерации такого кода можно отнести к области использования “высокоуровневого” C++.

Вызов кода API библиотек, предоставляющих интерфейс на этом языке
Часто какой-нибудь SDK представляет только C/C++ интерфейс для работы с ним (в качестве примера, можно взять OpenGL). Несмотря на то, что для многих библиотек существуют биндинги к популярным скриптовым языкам, особого преимущества при работе с ними не будет, если только биндинг каким-либо образом не добавляет библиотеке выразительности — какая разница, из какого языка вызывать сишные функции, по сути, по порядку? Возможно, определенные преимущества можно получить в языках, позволяющих менять семантику самого языка макросами, типа лиспа, но в рамках доступных для геймдева скриптовых языков особенных преимуществ не заметно.

Хардкорные C++-программисты
Отдельного упоминания заслуживают С++ программисты, которые противятся использованию скриптовых языков для описания игровой логики. Это труднообъяснимое явление, корни которого до конца я не понимаю, поэтому только приведу только те аргументы, которые от них слышал.

  • С++ быстрый язык, а скрипты — медленные
    С++ быстрый, если писать на низкоуровневом C++, в остальных случаях скрипт после AoT-компиляции может сконвертироваться в средний по качеству/скорости C++ код.
  • С++ выразительный язык, а скрипты — бейсикоподобные
    Отчасти верно, но зависит от объектной модели, прокинутой в скрипты. Большинство кода в игровой логике на C++, наоборот, используют примитивы слишком низкого уровня там, где отлично было бы обойтись только высокоуровневыми абстракциями. Ну и, какая к чёрту разница, есть или нет какие-то фичи в скриптовом языке, если он позволяет достичь результата раз в 5 быстрее?
  • Я хочу учить C++, чтобы развиваться только как C++ программист
    Один из самых трудных случаев. Человек верит в миф о самом лучшем языке, вдобавок подкрепив эту веру собственными усилиями, потраченными на изучения премудростей языка. Хз, если не помогают объяснения о том, что выгоднее развиваться не только в одном языке, то возможно, человеку лучше работать над компиляторами, а не в разработке игр.

Высокоуровневый C++

Высокоуровневые задачи, с которыми сталкивается типичная игра:

  • RTTI и рефлексия — сериализация данных, читы, статистика
  • Кодогенерация — введение удобного синтаксиса в язык, генерация типового кода
  • Compile/build-time вычисления

Один из существенных минусов C++ - отсутствие рефлексии, из-за чего её постоянно переизобретают. Часто потому, что к готовой схеме рефлексии сложно “привязаться”, чтобы использовать её для других целей. Например, разметив код один раз, необходимо воспользоваться этой разметкой, чтобы сгенерировать код сериализации или генерации биндингов к скриптовому языку.

Другая задача, которая решается различными способами — генерация типов и кода десериализации по схеме данных. Если решать её средствами C++ — макросами и шаблонами, то достаточно нетривиально скрестить её со схемой аннотации типов и генерации биндингов.

Подходы, которые применяются для кодогенерации RTTI-информации:

  • Использование схемы данных на отдельном языке (flatbuffers/protobuffers), с генерацией кода внешним компилятором
  • Использование языка, генерирующего данные и код работы с этими данными (DataCompiler на Racket от Naughty Dogs)
  • Встраивание команд языка в описание данных (jsonic)
  • Встраивание тегов в исходный язык, парсящихся языком, генерирующим данные по тегам (MOC, CppHeaderParser, парсинг с помощью libclang - половина либ отсюда)
  • Явное описание с помощью шаблонов C++ (rttr)

Непосредственно генерация кода часто выполняется в текстовом виде, либо с помощью генерации текста по множеству условий в коде генератора (пример - dascript), либо с помощью генерации текста с подстановкой по шаблону (чаще всего движки шаблонов изначально задумывались для генерации html-страниц, fulmar — зачаточный генератор С++ на Racket, с удовольствием нашёл бы что-то лучше).

По идее, не особо сложно генерировать — сериализиацию, биндинги, rtti, типовой C++ код вроде клонирования (сначала всегда хочется пытаться решить эту задачу просто с помощью наследования от “правильного” базового класса :) ).

Сложнее в C++ с тем, чтобы генерировать синтаксис по уже существующему синтаксису, язык не особо приспособлен для этого. Примеры задач:

  • Подсказка компилятору аллоцировать блок данных для структуры в линейном блоке памяти без явного написания кода работы с указателями в самой структуре, формулировка проблемы из Jai
  • Смена AoS/SoA выравнивания в памяти
  • Регистрация функций в ECS (атрибут EC для функций с авто-выведением аргументов)

Произвольные compile-time вычисления — чаще проще сделать на внешнем языке (экспериментальные попытки завести такое в C++ - Circle), и просто выстроить результат, чем пытаться выполнить что-то сложное компилятор.

Сейчас меня очень интересует в качестве скриптового языка DaScript, доклад Антона Юдинцева сильно корреллирует с моим представлением о том, зачем нужны скриптовые языки на “стандартном” уровне, и замахивается на то, чтобы решать “высокоуровневые” задачи, для которых не всегда есть стандартные решения в самом C++. Пока не успел поиграться с языком, но мне очень нравятся начальные идеи, с которыми подходили к разработке, и заявленные результаты. Я достаточно знаком с использованием скриптовых языков в Gaijin в прошлом, чтобы примерно представлять уровень и объём кода, написанного ими на языке в продакшене и в тулзах, чтобы предварительно положительно отнестись к заявленному, но и без этого выглядит довольно круто. В докладе и стриме почти не освещаются возможности языка по написанию макросов, упоминаемые в документации, а также в коде модулей и привязок либ для реализации “высокоуровневых” фичи.

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

Ссылки

Nim in imaginary world — критерии выбора языка
Заметки о языках программирования — сборник материалов по языкам
Джесси Шелл - Искусство Геймдизайна — “Итерации делают игру лучше”. Совет использовать для прототипирования языки с возможностью быстрой проверки изменений - Smalltalk, Python, Scheme, JavaScript
Джейсон Грегори - Архитектура игрового движка — 16.9.4 - Архитектуры для скриптования. Обзор возможных точек привязки скриптов к игре. 16.9.5 - Возможности игровых скриптов на этапе выполнения. Пример подхватывания изменённых данных игрой без перезапуска. 12.10.3 - Параметры состояния и дерева слияния - примеры анимационного клипа, описанного на языке Data Compiler, и Animation Blueprint для Unreal.
Demo: Base language, compile-time execution - пример запуска игры во время компиляции.
cr.h: A Simple C Hot Reload Header-only Library - трудности реализации hot code reload в C++. Больше статей и материалов.