Архитектурные компромиссы в разработке игр
У меня есть книга, которая называется Game++ и несколько статей, где я разбирал какие паттерны применяются в играх и движках. В книге почти сто страниц отведено про эти самые паттерны и подробно рассказано какие они бывают, как выглядят в C++, где у них подводные камни и как их применять. Т.е. ровно те мелочи реализации, которые обычно интересно перечитать, когда вы в очередной раз решаете делать фабрику отдельным классом или попробовать обойтись std::function. Когда я её писал, мне казалось, что это будет очень полезный практический текст, и он таким и получился, и человек с опытом довольно быстро находит там нужное.
Но если читать книгу целиком, а не эти отдельные главы, то хорошо видно, как я по неопытности и уверенности молодого автора в собственной правоте взял с места в карьер и сразу начал рассказывать про реализации, как будто читатель уже всё для себя решил и его интересует только синтаксис, предположив, что мы все тут делаем условный AAA-движок, в котором сериализация неизбежна, а скрипты обязательны. В результате получился классический случай, когда книжка отвечает на вопрос «как», но обходит вопрос «а собственно зачем», а без ответа на него все ответы про «как» оказываются либо случайно-полезными, либо системно-вредными, потому что человек берёт оттуда подход, переносит его в проект или прикручивает к своей мини-игре и потом жалуется, что у него теперь полторы тысячи строк инфраструктуры на ту же мини-игру, а работает она ровно так же, как раньше, только медленнее.
Эта статья родилась из попытки эту ошибку признать и исправить, и заодно из очередного перечитывания GoF, которую я первый раз увидел ещё студентом, и тогда мне она показалась слишком абстрактной и недостаточно практической. И теперь хочу вернуться на пару шагов назад и пройти этот путь правильно, начав не с того, как реализован паттерн, а с того, какую инженерную проблему он вообще решает, и в каких условиях он становится либо спасением проекта, либо лишним слоем абстракции, который дорого стоит и ничего не даёт.
Если вам вдруг надоест читать эти 106 минут, там в конце есть TL;DR секция, где собрано краткое описание.
Зачем вообще нужны паттерны
Большинство разговоров о паттернах начинается с того, что кто-то берёт GoF, открывает на случайной странице и говорит, что вот этот <подставьте свое>, мол, единственно правильный способ сделать фабрику или наблюдателя, и дальше принимается обсуждать, виртуальный там должен быть метод или невиртуальный, а если у нас компилятор еще не поддерживает рефлексию, то как мы тогда вообще будем жить. Это очень похоже на ситуацию, когда вы покупаете молоток в строительном магазине, а вам долго и подробно объясняют, с каким именно гвоздём в каком именно дереве он лучше всего работает, не уточнив сначала, дом вы строите или скворечник.
Первая опасность паттернов то, в реальной жизни это не правильный ответ на то какого размера скворешник мы будем строить на этот раз, а вполне себе технический компромисс между несколькими силами. Эти силы тянут вашу архитектуру в разные стороны, и без такого компромисса архитектуру просто порвет и никакие паттерны ей уже не помогут. Вторая, что когда вы читаете про «избегание изменений» или «слабую связанность», вам пытаются продать одну сторону медали, и довольно часто продают её, хотя в вашей конкретной игре это вообще не нужно.
Силы при этом бывают как вполне технические, вроде связанность против связности, кеш-локальности против гибкости, header hell против forward declaration и много чего другого, что довольно хорошо описано в учебниках. А бывают силы организационные, про которые в учебниках почти ничего не пишут, и которые в реальном проекте оказываются сильнее всех технических вместе взятых. Например, сколько у вас людей, какой у них опыт, легко ли договариваются, как часто будут меняться требования геймдизайна, придёт ли локализатор за китайским языком, и сколько раз техлид успеет посмотреть код движка и захотеть «вот это, только лучше».
Когда вы выбираете паттерн, вы на самом деле отвечаете на вопрос, где именно вы готовы заплатить и чем именно вы будете расплачиваться, и в зависимости от ответа правильным окажется либо тяжёлый data-driven с генерацией хедеров по схеме, либо плоский C++ файл на тысячу строк, где всё пишется руками и просто работает. Это условное упрощение.
Отсюда cтановитс понятно, почему вопросы вида «какая архитектура GUI самая лучшая» или «что выбрать, ECS или иерархию объектов» технически бессмысленны, ровно как и слово «универсальный» в описании движка, потому что у универсальности есть конкретная цена, и она измеряется в мегабайтах исходников, человеко-годах поддержки и количестве не написанных вами игр. Тим Суини как то высказал мысль, что если вы видите универсальный движок объёмом меньше ста мегабайт исходников, примерно столько весит Unreal, смело проходите, потому что весь код, который не написан в универсальном движке, придётся писать вам.
С другой стороны, тот же Unreal Engine, который размер сотни мегабайт давно превысил, тоже не универсальный, он построен под вполне конкретные жанры с конкретной моделью симуляции и репликации, и попытка собрать на нём, скажем, RTS уровня StarCraft II с её определёнными правилами синхронизации заканчивается либо переписыванием половины движка, либо переездом на что-то другое, но отговаривать вас попробовать это сделать, я, конечно, не буду.
Очень полезно держать в голове несколько прикладных примеров, на которых эта мысль становится очень наглядной. Когда id Software писали первый DOOM, у них было три программиста на все кор-системы, один компилятор, один таргет и очень понятные ограничения железа. Любой data-driven дизайн с генерацией кода по схеме был бы для них ровно тем же оверинжинирингом, и поэтому в исходниках лежит плотный, оптимизированный под кэш C-код, который очень тяжело расширить на что-то другое, зато легко читать.
id Software на момент DOOM не была «двумя людьми», а уже сложившейся компанией с 8 сотрудниками (Carmack, Romero, Hall, Petersen, два художника Carmack+Cloud, дизайнер). «Два программиста» тут скорее легенда, которую часто цитируют, но это история больше про Wolfenstein 3D (1992), когда движок писал Carmack один, плюс помогал Romero.
Вторая сторона медали, это BioWare, которые пятнадцать лет спустя делали Dragon Age. На тот момент у них были четыре десятка геймдизайнеров, локализация на десяток языков и почти сотня программистов движка и игры, и такой же подход с C-кодом гарантированно бы убил проект задолго до релиза. Поэтому команда сначала два года писала редактор, схемы и сериализацию, заплатив в инфраструктуре в десятки раз больше, чем id, но получив возможность нанимать дизайнеров, которые вообще не видяли и строчки сишного кода. Обе команды сделали правильный выбор, при этом выборы у них прямо противоположные, и это тоже нормально, потому что финальные цели у проектов были разные.
Поэтому полезнее всего относиться к паттернам не как к рецептам или словарю заклинаний, которые надо выучить и кидать в код, а как к незаконченному обсуждению и ровно тогда, когда вам нужно с тимлидом, с архитектором соседней подсистемы или с собой через полгода обговорить, какой именно компромисс вы выбрали и почему, паттерны начинают экономить время и нервы. И вместо часовой импровизации с маркером у доски вы говорите «у нас тут MVC, потому что вью переписывают чаще модели», право собеседник согласиться и всем пойти пить кофе на тераску, либо аргументированно возразить и тогда спор движется к решению, а не по кругу. Всё остальное про паттерны это уже технические детали, которые очень сильно зависят от языка, и о которых дальше речь и пойдёт.