
Похоже тема сложности и грязи в играх нашла отклик у читателей Хабра, и @anz (Привет, Андрей!) даже пообещал поставить тысячу плюсов если бы мог. Он кстати уже лет десять делает свой игровой движок с преферансом и дамами, и думаю повидал немало. Так что ловите вторую часть, которая должна была быть в части первой, но её там, почему-то, не было.
В основе любого инженерного искусства лежит великая иллюзия контроля над хаосом и открывая пустой файл, мы искренне верим, что в этот раз архитектура точно-точно останется кристально чистой. Никто в здравом уме не пишет сложный код ради удовольствия (хотя нет, пару человек я все-таки знаю, но это скорее исключение из правил), и если вы открыли систему частиц и увидели там хардкод для каждой платформы, то это, скорее всего, программисту, писавшему эту систему, выдали число, мало времени и сказади сделать красиво "читобыработало", но читобыработало - не всегда красиво, красиво - не всегда быстро, быстро - не всегда работает.
Число... например, 16 мс или 33 мс или 8 гигабайт, из которых 5 доступны, или «игра должна показать первый интерактивный кадр не позже чем через десять секунд после запуска, иначе сертификацию не пройдем». Как только у свойства системы появляется измеримая цель, простота перестаёт быть бесплатной и превращается в то, чем вы платите.
За всё приходится платить, и вместе с числом приходит ограничение, которое является измеримым свойством системы. Например, не «игра должна быть быстрой», а «кадр укладывается в 16 мс на базовой модели консоли» или не «игра должна нормально ставиться», а «размер начальной загрузки меньше лимита, после которого магазин требует Wi-Fi». Список того, что реально становится ограничением в производстве игр намного длиннее, чем кажется, и производительность в нём далеко не самый заметный пункт, последнее время на первый план вообще выходит не она, а контент и человеко-часы.
количество ассетов, которые может обслужить один художник за спринт, да, это тоже ограничение, просто оно измеряется не профайлером.
время сборки проекта и время итерации для дизайнера, то есть сколько проходит от правки до того, как правку видно в игре;
пиковое потребление памяти, на консоли нет свопа и превышение означает падение;
размер установки и, отдельно от него, размер патча, потому что патчи качают все и часто;
время кадра, отдельно на CPU и на GPU, отдельно в среднем и в 99-м перцентиле;
время холодного старта и время загрузки уровня;
тепловой пакет и потребление батареи на мобильных и портативных устройствах;
Как видите приоритет у оптимизаций находится где-то на уровне размера билда, и такое я наблюдаю последние лет десять, после захвата рынка движками-комбайнами. Дальше у каждого ограничения есть варианты, например производительность - это средний кадр или худший? А худший за сессию или худший за десять секунд? А пропускная способность стриминга или задержка отклика? Тридцать стабильных кадров лучше или хуже пяти дёрганых? Ответ как всегда зависит от жанра и платформы, и он же определяет, какую именно сложность придётся построить.
По научному это называется нефункциональными требованиями, но я считаю это определение довольно вредным, потому что создаётся впечатление чего-то второстепенного, хотя это просто значит, что требование имеет диапазон применения. Но на практике именно эти требования определяют архитектуру, а функциональные определяют как вы будете это делать.
Сложность, которую вы покупаете

Простых алгоритмов, решающих сложную задачу, мало. Сложных алгоритмов, решающих разные задачи - много и вероятность того, что случайно взятый простой алгоритм уложится в жёсткое число, заметно ниже, чем у сложного, просто потому, что у сложного больше степеней свободы, на которые можно влиять. Возьмите алгоритм поиска потенциально пересекающихся пар объектов на сцене, задача эта решается в любом движке, даже если там не физики вообще.
// Уровень 0: O(n²) for (int i = 0; i < n; ++i) for (int j = i + 1; j < n; ++j) if (overlap(aabb[i], aabb[j])) emit_pair(i, j); // 200 объектов дадут 20 тысяч проверок // 5000 объектов дадут уже 12.5 миллионов // Уровень 1: равномерная сетка // + быстро, понятно, легко отлаживать // - плохо на разнородных размерах один большой объект // попадает в сотню ячеек и ломает всю идею // Уровень 2: BVH с инкрементальным обновлением // + хорошо на любых размерах // - нужна перестройка, нужна эвристика разбиения, // нужен отдельный код для быстро движущихся объектов, // нужно решить, что делать с удалёнными узлами // Уровень 3: то же самое, но с SoA-раскладкой, // SIMD-проверкой по четыре пары за раз // и отдельным путём для статической геометрии // + ещё быстрее // - отладчик показывает кашу флоатов
Каждый следующий уровень быстрее и сложнее предыдущего, и это механизм покупки сложности: вы обмениваете понятность на попадание в число.Обратно простоту уже не выкупить, потому что через год после того, как вы написали уровень 3, никто уже не помнит, при каких условиях уровень 1 переставал справляться, и удалить появившуюся сложность становится страшно.
Думайте об этом как о запас простоты, и вы его тратите, чтобы купить время кадра, память, размер билда, устойчивость к падениям, приличное логирование. Иногда удаётся сэкономить и получить и то и другое, потому что кто-то придумал по-настоящему хорошее решение, но такое случается редко и обычно один раз на проект.
Два ограничения хуже, чем одно ограничение дважды

Самое неприятное свойство ограничений в том, что они взаимодействуют и имеют свойство комбинироваться, но почти всегда комбинация делает алгоритм хуже. Примером будет любой обмен памяти на время: любой кеш, любая предпосчитанная таблица, любой атлас улучшают одно за счёт другого.
Ограничение A: время загрузки уровня. Хотим читать последовательно, без seek'ов, значит, все ассеты уровня должны лежать рядом в порядке использования. Ограничение B: размер установки и размер патча. Хотим хранить каждый ассет ровно один раз и дедуплицировать всё, что можно. Эти два требования прямо противоположны: Оптимально под A: Оптимально под B: [level_01: A B C D E] [pool: A B C D E F G] [level_02: A B F G] [level_01 -> ссылки] [level_03: A C E G] [level_02 -> ссылки] A лежит трижды, A лежит один раз, читается одним махом три seek'а на загрузку
Оба варианта правильные и оба выполняют своё требование. Но попробовав их объединить вы получаете алгоритм, который дублирует только горячие ассеты, только для уровней с жёстким бюджетом загрузки, только начиная с определённого размера... да, это работает, но вы получаете в проекте систему с эвристиками, конфигом и отдельным человеком, задачей которого будет ходить по уровням и собирать ассеты для патчей, чтобы он весил триста мегабайт, а не три гигабайта.
И если код, который уже усложнён ради памяти, приходится усложнять ради времени кадра, то вторая порция сложности ложится поверх первой, умножая её.
Ограничения ломают модульность

Пока горячая точка одна, всё терпимо и вы её можете найти профайлером, написать там свой страшный, но локальный код, обкладываете комментариями и живёте дальше. Проблемы начинаются, когда нужно найти не двукратное ускорение в одном месте, а десять процентов по всему кадру, но чтобы получить прирост десять процентов для кадра надо найти десять мест по десять процентов, которые не лежат в горячей точке. Эти проценты лежат в накладных расходах, размазанных по всей кодовой базе.
А накладные расходы лежат в тех местах, за что мы обычно хвалим код. В слоях абстракции над платформой, в интерфейсах с виртуальными вызовами, в вынесенной общей функции или аккуратном shared_ptr и неочевидном владении, в менеджере и другом менеджере. И каждая такая вещь стоит полпроцента, и «немного» становится существенным, когда вам нужны десять процентов, но явно взять их неоткуда.
// Было: правильно и модульно for (IComponent* c : components) c->Update(dt); // виртуальный вызов, промах по I-cache, // объекты разбросаны по куче // Стало: неправильно и быстро UpdateTransforms(transform_soa, count, dt); UpdateAnimators(animator_soa, count, dt); UpdateColliders(collider_soa, count, dt); // компоненты больше не полиморфны, порядок обновления // зашит в вызывающий код, добавление нового типа // требует правки в четырёх местах
Это тот самый data-oriented design, который пришел в игровую разработку, но обратите внимание, что произошло - мы не просто переписали цикл, но протащили устройство процессорного кеша через три слоя архитектуры наверх, до места, где определяется, как вообще устроены сущности в игре. Ограничение продырявило все абстракции и программа, которая по идее не должна знать про железо, теперь построена вокруг длины кеш-линии.
В моей прошлой статье про память консолей вся история, по сути, про это же. Три острова памяти на PS2 были деталью реализации железа, о которой не должны были знать разработчики игр, но они определяли форму игрового кода целиком, потому что явные DMA-передачи между островами невозможно спрятать за абстракцией.
Жёсткие и мягкие пределы

Мягкий предел можно превысить немного или изредка и просадка до 45 кадров в катсцене попадёт в баг-трекер с приоритетом «после релиза», а загрузку уровня за двенадцать секунд вместо десяти можно попросить "пропустить" на сертификации.
Жёсткий же предел не получается изменить и у вас физически нет больше 5.5 гигабайт доступной памяти, у вас есть ровно столько, сколько есть, и превышение уже не тормоза, а краш и провал сертификации или сдвиг даты релиза.
Отсюда, кстати, растут и все бюджеты подсистем, на которые ориентируются консольщики, а потом эти бюджета определяют сколько у вас будет объектов на уровне и какого качества получатся текстуры.
Бюджет памяти, базовая консоль, доступно 5120 МБ: исполняемый код и статические данные 180 текстуры (резидентные) 1600 текстуры (стриминговый пул) 1200 меши и анимации 700 звук 420 физика и навигация 260 скрипты и игровая логика 300 рендер-таргеты и промежуточные буферы 380 резерв на фрагментацию и пики 80 ----- 5120 Резерв 80 МБ это не «запас на всякий случай», это час игры пока фрагментация поджирает память, считайте что их нет
Эти бюджеты тоже сложность, их надо поддерживать, проверять, писать инструменты, которые покажут, какая команда съела чужие мегабайты, но ничего из этого не понадобилось бы, будь предел мягким как на ПК.
Парадокс Джевонса и тут тоже

Я уже писал про Уильяма Джевонса, который заметил, что рост эффективности паровых машин увеличил потребление угля в Англии, потому что уголь стал дешевле в пересчёте на полезную работу и его начали использовать там, где раньше было невыгодно. С ограничениями в разработке происходит то же самое.
Вы улучшили метрику, например, и метрика перестала быть узким местом. Тогде люди, которые были её ограничены, например дизайнеры, меняют поведение. Появляются сценарии использования, которых раньше не существовало, потому что они были физически невозможны и эти сценарии приносят новые требования и новую сложность, часто больше, чем вы сэкономили.
Быстрый SSD на нынешнем поколении снял ограничение на скорость подгрузки, из-за которого раньше строили коридоры, лифты и узкие проходы. Прекрасно, но в результате миры выросли, дальность видимости выросла, требования к плотности контента выросли, и вместо системы стриминга с предсказанием появилась система управления резидентностью виртуальных текстур, которая настолько сложная, что один человек уже не в силах её спроектировать и написать.
Мы ускорили пересборку контента с пяти часов до сорока минут, и это было хорошее, честное инженерное достижение. Через два месяца оказалось, что дизайнеры теперь итерируют в пять раз чаще и количество версий уровней в системе контроля версий выросло на порядок, QA перестал успевать проверять изменения, и понадобилось увеличивать количество тестировщиков. Суммарная сложность проекта выросла, но не там где вы ожидали.
Мы сделали горячую перезагрузку скриптов, чтобы не перезапускать игру и через месяц выяснилось, что никто больше не перезапускает игру вообще, и половина багов, которые ловят на плейтесте, это баги накопленного состояния, невоспроизводимые с чистого старта. Пришлось писать проверку консистентности состояния после перезагрузки, то есть новую подсистему, которой до оптимизации не требовалось.
Я не предлагаю вам ничего не оптимизировать, просто помните, что бюджет сложности после успешной оптимизации не высвобождается, а переезжает в другое место, часто неочевидное для оптимизатора. Никогда не будет что вы сэкономили десять процентов кадра, на них уже есть пара загребущих ручек, о которых вы не знаете.
Ограничение, которого нет в документе
«Игра должна нормально работать на минимальной конфигурации» это не ограничение, это пожелание, потому что нельзя проверить автоматически, нельзя поставить в билд-фаил, нельзя предъявить на планировании. Оно превращается в ограничение когда вы запускает билд на минимальной конфигурации и обнаруживает восемь кадров фпс, и вам приходится вносить в проект столько сложности за оставшееся время, сколько при нормальной работе накопилось бы за год.
Ограничение без числа и без автоматической проверки перестает быть ограничением, и становится способом отложить плату по счетам, причём с процентами. Сложность, добавленная в панике за месяц до релиза, всегда дороже и хуже той же по функции сложности, добавленной в спокойном режиме, и обе остаётся в проекте навсегда.
Сложность всегда идёт туда, где стоит измеритель и если у вас автоматически меряется только время кадра, у вас будет вылизанный код кадра и тридцать секунд на загрузку. Если добавите проверку загрузки, получите обе хорошие метрики и размер патча в полтора гигабайта, не потому, что команда халтурит, просто команда рациональна и оптимизирует что проверяют. Что проверяют, то и будет хорошо, а что не проверяют... ну не проверяют и хорошо, потом игроки проверят.

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