...или почему игровые движки всегда заканчивают одинаково.

Вы задумывались когда-нибудь о том, что самый дорогой движок в индустрии написан так, что его проще выбросить и написать заново, чем понять и починить. Или что самая успешная студия десять лет жила на коде, который сами авторы называли «отколбашенным» и в русском языке есть более подходящее понятие, начинается на г... заканчивается на ...код. А правильный с точки зрения архитектуры движок умирает в безвестности, потому что деньги у компании на его развитие закончились. Если не узнали примеры, то в конце статьи будет пояснение.

Любой движок, на котором есть "живые" игры проходит через четыре стадии: «хоть как-то, работает», «работает, но стыдно показать», «не стыдно показать, но никто не знает как работает», и «работает на отдельном языке, который мы сами же и придумали». Потом круг замыкается, и начинается новый виток истории, потому что команда осознает, что дальше так жить нельзя.

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


Архитектуру и дизайн всегда делают разные люди

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

Потому что архитектура, это про процессы и результаты, а дизайн систем почти всегда про решения и связи. И главное в работе дизайнера это не написать хорошо вместо плохо, дизайн начинается там, где оба крайних варианта хорошие, но тянут в разные стороны, тогда у выбора будут далекоидущие последствия, которые аукнутся года через три. А в первом прототипе движка обычно все плохо, и дизайнер тут обычно катится рядышком пятым колесом, активно мешая команде. Работа дизайнера сделать так, чтобы по всем осям разработки получалось минимальное сопротивление при написании кода и создании контента. Код и контент находятся на разных полюсах разработки, поэтому очень часто это сложно, иногда невозможно.

Гранулярность:   гибкость <───────●──────> простота
Связанность:     несвязанность <──●───────> запутанность   (направо не ходить)
Гибкость:        приложение <─────●──────> библиотека
Связность:       удобство <───────●──────> ортогональность

Потому что силы, которые тянут ползунок туда или сюда, почти никогда не про программирование, а про деньги и людей: cколько у вас программистов, сколько артистов, кто кому платит зарплату и кто на ком будет экономить. Архитектор же проектирует систему систем, и ему нет дела до вас, денег и людей... конечно, если это хороший архитектор систем.

Паттерны не про язык программирования

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

Такие разные паттерны

pattern не фиксирует форму, а описывает силы (forces) и то, как они разрешаются, оставляя конкретную реализацию открытой. GoF унаследовали слово, но слегка выхолостили смысл и их паттерны куда ближе к шаблонам.

template жёсткая форма с дырками для подстановки. C++ template будет предельным случаем, когда инстанцирование детерминировано, вариаций тут нет, только по типу-параметру.

idiom как низкоуровневая, языко-специфичная конвенция («RAII», «CRTP», «copy-swap»). Устоявшийся оборот речи в конкретном языке, и ближе всего к лингвистическому смыслу слова как узнаваемый оборот.

blueprint это план, который надо выполнить буквально, т.е. инженерный чертёж.

archetype как прообраз, из которого варианты порождаются через отклонение, а не через подстановку. archetype ближе к «видению», чем к шаблону, но в другую сторону и это скорее источник решение, а не сеть похожих решений.

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

schema абстрактная структура данных/отношений, из области баз данных и больше не про решение проблемы, а про форму организации данных.

convention договорённость, ничем не мотивированная кроме соглашения нескольких людей («naming convention»).

paradigm на порядок выше по уровню абстракции, целый способ мышления (ООП, функциональщина).

Многие эти слова в русском комьюнити заменяют на одно "паттерн". Менять терминологию, конечно, уже поздно, но в голове держите что вместе с паттерном надо читать и контекст, где это слово применяют. Когда говорят "паттерны проектирования", то обычно вспоминают GoF с их фабриками, синглтонами и прочим зверинцем, но почти всё оттуда это design for change, то есть мы уже внутри картинки мира «как нам это программировать» и эти паттерны больше template, blueprint и idiom, нежели сами паттерны.

Но как бы ни хорош был архитектор и дизайнером, угадать масштаб будущей катастрофы с первого раза почти невозможно, поэтому большинство стартует с того, что любовно называют быстро(тут другое слово)-кодингом, он же «кодим как в 1999-м», а потом начинается самое интересное, потому при кажущемся просторе развития, путей по которым идут движки всего четыре. В жизни любой студии, которая пишет свой движок (назовем это Фаза-0), наступает момент «а давайте сделаем конструктор», и мы переходим в Фазу-1...

Все есть объект...

Здесь обычно есть наколбашенный код одной-двух игр, где там всё сделано классами. Чтобы все было сделано классами, надо иметь GameObject, он же GodObject, который знает всё обо всех. И есть второй Бог, он жеGameLevel, который передаётся повсюду и тоже знает всё. Сейчас всё связано со всем, наш ГоблинВожак знает как работает солнце в небе, потому что солнце тоже GameObject

class GoblinLeader : public Goblin { 
  /* параметры + поведение */
};
// иерархия примерно такая:
// GoblinLeader -> Goblin -> Enemy -> AiObject -> MoveableObject -> GameObject

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

Фаза-1 очень кривая, зато работает, и работает долго. На тему GodObject есть прекрасная байка из "Блицкрига" от самих разработчиков, когда в какой-то момент нейтральные свиньи отнаследовались от боевого юнита и, увидев врага, патриотично лезли за оружием, которого у них не было, а патроны к нему были из-за неиниченной переменной, а так как оружия у них было, то они массово нулпоинтерели и крашили игру. Починили краш, выдав свиньям парабеллум, но забрав патроны, т.е. проинитив переменную в 0.

В этой байке весь смысл фазы-1, когда весь движок живёт наследованием от одного класса, и баги лечатся наследованием же. Дальше начинается фаза-2 «дальше так жить нельзя», но как я уже сказал, она может отстоять по времени очень далеко. Настолько далеко, что могут пройти годы, пока команда найдет в себе силы, время и деньги для перехода в фазу-2. Можете посмотреть на Wargaming с его более чем десятью лет в проде, кучей игр под все платформы, еженедельных апдейтов... и как они сами признают, движок находится все еще в фазе-1, полёт нормальный.

... но не все можно объектом сделать

Наконец набирается критическая масса «так жить нельзя» и выделяется команда, которая начинает наводить красоту. Появляются модули, менеджеры, стройные классы, появляется динамика и вместо жёсткого наследования начинаются вопросы «а есть ли у объекта компонент с таким именем?».

GameObject* player = scene.findGameObject("Player");

if (!player->hasComponent("ControlComponent")) 
  player->addComponent(new ControlComponent());

player->update();

GameObject худеет до контейнера компонентов, связность кода падает, связанность систем растет. Когда связность падает это всегда хорошо, а плохо то, что взамен прилетает обилие приведений типов, поиск подобъектов руками, умные указатели повсюду (потому что отличить целую сущность от её части теперь невозможно) и messaging, это такой механизм который позволяет вам сообщить объекту что он уже мертв, а как он будет мертв и кто его удалит решает уже другая система. А самый треш, как обычно, всегда оседает в системе GUI, фраза "так исторически сложилось", как раз больше всего про GUI.

Обычно фаза-2 занимает около года или двух, потому что её делают программисты. Год, на самом деле, это очень-очень мало для того чтобы мигрировать большой проект, и чем меньше по времени оказась фаза-2, тем лучше код был перед чисткой. Для примера у Unreal миграция заняла почти 7 лет, с 2007 года, когда они объявили, что будут двигаться в сторону конфигов-кода, до 2014 года, когда это всё наконец заработало.

... но можно сделать конфигом

А дальше начинает происходить магия, на которую уже не в силах повлиять программисты, которые её написали. Если у нас уже есть описание объекта в данных, зачем вообще держать класс GoblinLeader в коде? Уберём. Пусть объект собирается из внешнего файла.

// больше нет класса GoblinLeader, есть описание

{ "GoblinLeader": {
    "EnemyComponent":    { /* ... */ },
    "MoveableComponent": { /* ... */ },
    "BossComponent":     { /* ... */ }
}}

// ^ в файл уехали не только константы, но и сам рецепт сборки объекта

Тут обычно приезжает грузовик с граблями и оказывается что описание простого окошка уехало куда-то в XML и теперь этим XML заведует Ваня-Джон-Вапур, которому тоже надо платить деньги, а он за эти деньги будет писать координаты в XML. И Ваня-джун-программист становится внезапно техническим-художником, а вся красивая ООП-модель приложения уезжает в метамодель оконной системы, которую настраивают другие люди и приложение, с точки зрения программиста, становится чужим и незнакомым.

Программист теперь не знает «кто», и гоблина-вожака в коде нет, есть его отдельные части, и даже эти части не полные, а собираются из каких-то других частей. А для кода отрисовки вожак и вовсем ничем не отличается от камня или свиньи, и вполне может хранить парабелум без патронов, или даже патроны без парабелума отдельно и без желания скрашить игру. Зато гейм-дизайнер собирает из компонентов то, что программист и не задумывал, и чинит свиней без программиста, а иногда и быстрее него.

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

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

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

... через свой язык программирования

Круг замыкается, теперь весь игровой код вынесен из движка, а сам движок превратился в виртуальную машину, а вся игра стала просто контентом и остается дать геймдизайнерам возможность создавать контент самим, без участия программистов.

ScriptEngine::ExecuteScript("GoblinLeader");
// "GoblinLeader" файл, где описаны и параметры, и логика поведения, плюсового кода 0.001%

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

... data-driven неизбежен

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

Если для окупаемости игры хватает трёх программистов и кода соответствующего качества, то у вас и будет три программиста и код соответствующего качества. И это нормально. Ранние состояния App Store, гиперказуалки, эпоха появления домашних ПК - всё это были примеры, где наводить data-driven-красоту было оверкиллом. К тому же сейчас не 2006 год и можно взять Unity, Unreal или Godot и использовать их хоть как data-driven-конструктор, хоть в чистом "щанакодим" стиле. А уж устроить кашу можно что в коде, что в данных... это вообще любимое занятие начинающих команд.

... to be continued

Двадцать лет назад умные люди уже описали этот путь от объектов к данным и обратно к языкам. Можно было бы подумать, что сейчас все давно пишут «как в фазе 4», но в реальности живые компании сидят на всех четырёх фазах одновременно, и это нормально, потому что фаза диктуется не модой, хотя и модой тоже... (вот ECS стал таким каргокультом), а деньгами и составом команды.

Унификация инструментов, общие движки, визуальные скрипты: всё это никуда не делось, но магия программирования из мира тоже не исчезла и программисты по-прежнему нужны, и игровые, и движковые, и технические, и ui, и скриптовые, и звуковые... и черт-еще-знает-какие.

Колесо Грегори продолжает крутиться и следом за желанием собрать всю игру на визуальном языке придёт желание получить производительность, а за ним появится желание лучше управлять схемой данных, и где-то на этом витке снова захочется свой DSL, или blueprint, или daslang и всё неминуемо начнётся с начала.

Материалы

З.Ы. Так вот, обещанное пояснение про первый абзац.

Самый дорогой движок, который проще выбросить, чем понять и починить, это CryEngine, точнее его форк, на котором Крис Робертс уже больше десяти лет пилит Star Citizen и сотни миллионов долларов краудфандинга ушли не в последнюю очередь на то, чтобы переписывать движок под себя. Даже движок (движок, не сама игра) GTA VI оценивается индустрией "всего" в 220M$, почти в два раза дешевле чем Star Citizen.

Студия, которая десять лет прожила на г...коде это Mojang и оригинальная Java-кодовая база Minecraft, которую сообщество и сами разработчики в шутку называли "нельзя удалять строки", но это не помешало игре стать самой продаваемой в истории.

А архитектурно правильный движок, который умер потому что у компании кончились деньги на его развитие - это Bitsquid, позже переименованный в Autodesk Stingray с чистой data-oriented архитектурой с самого начала, о которой до сих пор с придыханием пишут в блогах матерые игроделы. Был закрыт в 2018-м, потому что рынок массово ушёл в Unity и Unreal, а идеальная архитектура без реальных проектов никому оказалась не нужна.