Пролог
Я хотел написать продолжение о том, как используется C++ в игровых движках, но мысли увели меня в неожиданную сторону. Язык в последние годы развивается стремительно — вот только получить и, особенно, применить возможности C++20/23 в разработке игр и движков получится хорошо, если с опозданием лет эдак на пять.
Парадокс в том, что студии намертво прикованы к C++ гигантскими легаси-кодовыми базами — они too big to fall. Переписать всё это на каком-нибудь модном альтернативном языке невозможно, какие бы теоретические преимущества он ни сулил. Так что давайте посмотрим, на каких уровнях C++ вообще живёт в движке — их условно три, и у каждого свои приоритеты.
Hardware / Baremetal / Hardcore C++
Самый нижний уровень — числодробилки и работа с большими объёмами вычислений. Именно этот код съедает порядка 80% времени работы крупных игр, и за счёт него удаётся выигрывать в производительности в десятки, а то и сотни раз по сравнению с «обычным» C++.
Код здесь намеренно ограничен: минимум вызовов функций, агрессивный инлайнинг, аккуратная работа с предсказателем переходов, точная упаковка структур данных. Он больше похож на C, чем на современный C++, — читаемостью сознательно жертвуют ради чистой скорости. И писать его умеет далеко не каждый: это не уровень обычного «general»-мидла, тут нужны разработчики высшего эшелона.
Middleware / Common C++ / Templates
Поднимаясь выше по слоям архитектуры, мы попадаем на уровень «обычного» C++. Этот код написан с применением классических «технологий» и алгоритмов, которые изобрели за время развития языка. Здесь располагается 80% кода, который применяется в софте. Сотни библиотек на разных языках, которые в том или ином виде предоставляют доступ к своим возможностям через «C-интерфейс». Различные связки с кор-языком ОС — например, Java через JNI, Objective-C++, виртуальные машины скриптовых языков.
Здесь же язык раскрывается как высокоуровневое средство проектирования — заметьте, не язык написания кода, а именно средство для описания архитектуры приложения (OOD, DOD, DDD). Оно позволяет не только выжать все соки из железа, наплевав на все правила хорошего кода, но и показать тот самый хороший код — устойчивый к ошибкам, утечкам, проблемам с bound-check-доступом и защищённый от джуна. К сожалению, во многих игровых движках здесь ещё остались ошмётки «ревущих» нулевых, когда плюсы вовсю использовались для написания игровой логики; вы можете заметить это, например, по доступным исходникам Unreal или Dagor, где кор-логика, связанная с игроком, частично присутствует на самом нижнем уровне объектов.
Ну и конечно, язык предоставляет доступ к API библиотек. А при использовании некоторых хаков вроде privablic-доступа — то и вообще к большей части скрытой от конечного пользователя функциональности. Но если вы думаете, что вот он, настоящий C++, — то нет: здесь всё ещё живут призраки «plain C», то там, то тут можно увидеть специально упрощённый функционал, чтобы этим уровнем могло пользоваться как можно больше людей.
Если прикинуть примерную производительность вычислений в зависимости от используемого уровня технологий, то обычный C++ задействует менее 10% возможностей железа, — так что неудивительно, когда разработчики готовы разменять продуктивность в человеко-часах на скорость работы.
Производительность вычислений в зависимости от уровня используемых технологий
«Мы с радостью пожертвуем 10% продуктивности ради того, чтобы получить 10%»
— Tim Sweeney
Если кто забыл, как он выглядит
Выливается это в то, что в движок приходят виртуальные машины языков второго и третьего уровня, которые позволяют, с одной стороны, писать скоростные алгоритмы на уровне движка, а с другой — оградить дизайнеров от C++ в пользу чего-то более медленного, удобного и понятного. Сначала это была мода на затаскивание скриптовых языков (Lua / JS / Squirrel / «напишите свой»), чуть позже пришло время визуального программирования. Скрипты и визуальные скрипты (blueprints) — это тоже не изобретение игростроя, они пришли из мира робототехники, где цена ошибки значительно выше и сама ошибка может привести не просто к вылету на рабочий стол, а к реальному повреждению оборудования. Минус такого подхода — то, что можно написать в 10 строках кода, займёт 1000 строк за счёт обвязки, проверок, тулов и т. д.
Про снижение производительности и говорить не приходится: даже самая продвинутая Lua VM, как бы ни заявляли её разработчики, просаживает перф хорошо если только в два раза. Возможно, на каких-то синтетических тестах падение производительности составляет десять процентов и меньше, но в реальной игре код из этого теста выполняется 0,1% времени работы. Это не так критично, как кажется на первый взгляд, потому что всё компенсируется ростом скорости памяти, процессоров и видеокарт. Но падение производительности оценивается не только в терафлопсах — сам язык Lua намного проще, чем плюсы. И люди — программисты и дизайнеры — тоже начинают думать и писать в парадигме упрощённого языка, просто потому что писать сложнее и не надо, да и не всегда получается.
По моему опыту, код, переписанный со скриптовых языков обратно на C++, будет быстрее в 5+ раз. Обычно так и происходит, когда по результатам профилирования игры определяются медленные участки. Другие скриптовые языки не сильно далеко ушли от Lua, внимание на котором в разработке было акцентировано как минимум лет десять, и за это время его очень прилично ускорили. С момента появления языка в далёком 1993 году производительность самой виртуальной машины, безотносительно производительности железа, выросла почти в десять раз.
Бенчмарки реализаций алгоритмов в разных версиях Lua VM; красным — эталонное время на C
Необходимость создания биндингов из плюсов в скриптовый язык — это ещё одно бутылочное горлышко при использовании связок C++ ↔ скрипты, зачастую из-за необходимости копировать данные между уровнями представления. Потеря на всех этапах делается, чтобы дать возможность программировать всем — от художника до дизайнера ИИ и системных механик, — дать возможность ошибаться и писать полную дичь, не уронив при этом редактор неосторожным движением шаловливых ручек.
Но конечно, главный профит, ради которого разработчики игровых движков идут на существенное замедление, — это возможность hot-reload игровой логики. Из коробки этого не будет, более того, это потребует переделки половины уже существующего кода, зато позволит ускорить разработку игры в десятки раз. Сами посудите: редактирование кода в среде разработки, компиляция, перезапуск уровня, создание игровой ситуации для работы — это всё минуты реального времени; хотрелоад скрипта — секунды, и при этом программист и дизайнер не выпадают из контекста игровой ситуации.
Ещё дальше в этом плане шагнули Unity и Unreal, предоставив возможности визуального скриптования и редактирования объектов и логики прямо во время симуляции, что ещё больше снижает требования к базовым знаниям разработки в целом и программирования в частности. Наверное, так и должны разрабатываться игры — когда ты просто меняешь состояние игры прямо во время игры. Как и в случае перехода от нативного кода к скриптам, так и от скриптов к визуальному программированию, это ещё больше замедляет общий код игры, но даёт ещё больше защиты от ошибок для команды. Теперь уже скрипты и ВМ выступают в роли фреймворка нижнего уровня, а на уровне визуальных скриптов вы на 95% защищены от возможности уронить игру, при этом получая доступ ко всему функционалу движка — от шейдеров до анимаций и поведения NPC.
Это, однако, не гарантирует, что разработка будет легче — я бы сказал, наоборот: разработка становится сложнее в целом, но эта сложность размазана между сотнями и тысячами элементов игры. Ну и конечно, факапить можно похуже и намного быстрее, чем в коде. Такую сложность из реального проекта назовём WTF/s. Честно — подобное никто не будет ревьюить, апрувнут не глядя; молитесь только, чтобы этот ГД довёл своего монстра до релиза.
Так никогда не делайте!
Meta / Highlevel C++
Подбираемся к самой мякотке. Кроме обычного плюсового кода, есть ещё небольшие части игрового движка, которые требуют использования самых навороченных языковых средств. Это RTTI, reflection, compile-time-расчёты и средства кодогенерации, когда код игры вырастает из набора конфигов по заданным наборам правил.
RTTI по понятным причинам в 99% случаев выключают, но сама необходимость каста к нужному типу никуда не делась, поэтому почти всегда пишут свою погремушку.
Из-за отсутствия рефлексии в самом языке её «изобретает» каждая вторая студия — у кого как получится. Готовой и проверенной схемы и технологии рефлексии нет — каждый фреймворк предлагает свои методы разметки кода, сериализации и биндингов.
Генерация типов и кода по конфигам — чтобы и скрипты умели их обрабатывать, и движок-игра имели доступ к этим типам. Обычно эта задача решается макросами, шаблонами и чёрной магией, что в итоге выливается в достаточно нетривиальный код или вообще в отдельную виртуальную машину со своим языком.
Из известных «хороших» кодогенераторов могу отметить следующие:
- Схема данных на отдельном переносимом языке — FlatBuffers.
- Отдельный язык генерации данных и кода для работы с ними (Racket от Naughty Dog) — доклад на GDC Vault и видео.
- CppHeaderParser — python-библиотека из одного файла, которая умеет читать хедеры.
- RTTR — позволяет создавать и изменять типы, классы, методы и свойства объектов на C++ во время выполнения программы.
Мысли опосля…
Возвращаясь в реальный мир после просмотра примеров из новых стандартов языка на YouTube или CppCon — когда лямбда, обёрнутая в memfunction, скользит по корутинам, — в очередной раз, после бессонной ночи, глядя в отладчик и исписанный блокнот, обнаруживаю какую-нибудь странную строчку кода, из-за которой непонятно, как вообще это всё работало. И в сотый раз задумываюсь: если такое написали ещё в C++11, то как же изощрённо это могут сделать по-новому? И как долго потом будут эту багу искать. Игры всё-таки пишут с какой-то целью, и просто переписывать код туда-сюда ради рефакторинга — плохая затея. Может, и хорошо, что мы живём в своём маленьком C++-мирке, охраняемом святой троицей Sony, Microsoft и Nintendo, которые не пускают сюда драконов из комитета?
← Все статьи