Project Hospital — это игра об управлении зданием больницы со всеми стандартными аспектами жанра: динамическими сценами, создаваемыми игроком, множеством активных персонажей и объектов, развёрнутой системой UI. Чтобы заставить игру работать на разном оборудовании, нам пришлось приложить много усилий, и это стало отличным примером печально известной «смерти от тысячи порезов» — множества мелких шагов, решающих кучу очень специфических проблем и кучи времени, потраченного на профилирование.
Уровень производительности: чего мы хотели достичь
На раннем этапе разработки мы определились с основными параметрами: максимальной величиной сцен, уровнем производительности и системными требованиями.
Мы поставили перед собой задачу обеспечить поддержку не менее сотни активных и полностью анимированных персонажей на одном экране, трёх сотен активных персонажей суммарно, тайловых карт размером примерно 100x100 и до четырёх этажей в здании.
Мы твёрдо были уверены, что игра должна работать в 1080p с приличной частотой кадров даже на интегрированных графических картах, и саму по себе эту цель достичь было не так трудно: основным ограничивающим фактором является ЦП, особенно при увеличении объёмов больницы. Современные интегрированные видеокарты начинают испытывать проблемы только при разрешениях примерно от 2560 x 1440.
Чтобы упростить поддержку модов, бОльшая часть данных сделана открытой, то есть нам пришлось пожертвовать производительностью, достигаемой благодаря упаковке файлов, но это оказало особо сильного влияния, за исключением чуть увеличившегося времени загрузки.
Графика
Project Hospital — это «классическая» изометрическая 2D-игра, поэтому можно понять, что всё отрисовывается сзади вперёд — в Unity это реализуется заданием соответствующих значений по оси Z (или расстояния до камеры) для отдельных графических объектов. По возможности не взаимодействующие друг с другом объекты упорядочиваются в слои, например, полы не зависят от объектов и персонажей.
Вся геометрия в изометрически рендерящейся сцене динамически создаётся на C#, поэтому одним из двух наиболее важных аспектов для производительности графики является частота перестроения геометрии. Второй аспект — это количество вызовов отрисовки (draw calls).
Вызовы отрисовки
Количество отрисовываемых в одном кадре отдельных объектов, вне зависимости от их простоты — это главное ограничение, особенно на слабом оборудовании (кроме того, сам движок Unity добавляет избыточное потребление ресурсов). Очевидным решением является группирование (батчинг) по возможности нескольких графических объектов в один вызов отрисовки. Так можно получить довольно интересные результаты, например, сгруппировать объекты, находящиеся на одинаковом расстоянии от камеры, чтобы вся остальная графика правильно рендерилась за или перед ними.
Вот немного цифр: на карте размером 96 x 96 тайлов теоретически можно разместить 9216 объектов, для чего потребуется 9216 вызовов отрисовки. После батчинга это число снижается до 192.
Однако в реальной жизни всё немного сложнее, потому что можно группировать только объекты с одинаковой текстурой, из-за чего результаты оказываются немного менее оптимальными, но система всё равно работает достаточно хорошо.
БОльшая часть батчинга выполняется вручную, чтобы иметь контроль за результатами. Кроме того, в крайнем случае мы также пользуемся динамическим батчингом Unity, но это палка о двух концах — он и в самом деле помогает снизить количество вызовов отрисовки, но приводит к излишней трате ресурсов в каждом кадре, а в некоторых случаях может быть непредсказуем. Например, два наложенных друг на друга спрайта на одинаковом расстоянии от камеры в разных кадрах могут рендериться в различном порядке, что вызывает мерцание, которого при батчинге вручную не появляется.
Многоэтажность
Игроки могут строить здания с несколькими этажами, и это повышает сложность, но, на удивление, помогает производительности. Рендерить и анимировать нужно только персонажей на активном этаже и на улице, а всё на других этажах больницы можно скрыть.
Шейдеры
В Project Hospital используются относительно простые самописные шейдеры с небольшими трюками, например, заменой цвета. Допустим, шейдер персонажа может заменять до пяти цветов (в зависимости от условий в коде шейдера), а потому довольно затратен, но это, похоже, не вызывает проблем, потому что персонажи редко занимают много места на экране. Шейдер оправдал вложенные в него усилия, потому что возможность использования бесконечного количества цветов одежды позволяет сильно повысить вариативность персонажей и окружения.
Кроме того, мы достаточно быстро научились избегать задания параметров шейдеров и вместо этого использовали по возможности цвета вершин.
Качество текстур
Интересный факт — в Project Hospital мы не используем никакого сжатия текстур: графика выполнена в векторном стиле, и на некоторых текстурах сжатие выглядит очень плохо.
Для экономии памяти ЦП в системах с менее 1 ГБ мы автоматически уменьшаем размер внутриигровых текстур до половинного разрешения (за исключением текстур интерфейса пользователя) — это можно понять, увидев в опциях параметр «texture quality: low». Для текстур UI сохраняется исходное разрешение.
Оптимизация производительности процессора — многопоточность
Хотя логика скриптов Unity по сути является однопоточной, мы всегда имеем возможность запустить несколько потоков непосредственно в C#. Возможно, такой подход не подойдёт для игровой логики, но часто существуют не критичные ко времени задачи, которые можно выполнять в отдельных потоках, организовав систему задач. В нашем случае потоки использовались для двух функций:
1. Задачи поиска пути, особенно на больших картах с запутанным расположением, могут занимать до сотен миллисекунд, поэтому это был основной кандидат на перенос из основного потока. Параллельные задачи учитывают количество аппаратных потоков машины.
2. Карты освещения тоже можно обновлять в отдельном потоке, но только по одному этажу за раз — это не критичная система, а автоматические лампы в комнатах гаснут с такой скоростью, для которой достаточно редкого обновления.
Анимации
Почти в самом начале разработки мы решили использовать двухмерную скелетную систему анимаций. Изучив различные современные программы анимации, мы в конечном итоге приняли решение модифицировать простую систему, созданную мной несколько лет назад (по сути в качестве хобби-проект), подстроив её под нужды Project Hospital — она напоминает упрощённый Spine с прямой поддержкой создания вариаций персонажей. Аналогично Spine она использует исполняющую среду C#, что очевидно более затратно, чем нативный код, поэтому в процессе разработки мы провели пару циклов оптимизаций. К счастью, наши риги довольно просты, всего около 20 костей на персонажа.
Любопытный факт: самым полезным улучшением при оптимизации доступа к transform отдельных костей оказался переход от поиска по карте к простому индексированию массивов.
Кроме того, что персонажи не анимируются за пределами камеры, есть ещё один трюк: персонажей, скрытых за окнами основного UI, тоже анимировать не нужно. К сожалению, в финальной версии игры мы перешли к полупрозрачному UI, поэтому использовать его не удалось.
Кэширование
По возможности мы стараемся выполнять самые затратные вычисления только при изменениях, влияющих на их значения. Самый хороший пример этого — комнаты и лифты: когда игрок размещает лифт или строит стены, мы запускаем алгоритм заливки, помечающий тайлы, из которых доступны лифты и комнаты. Это ускоряет последующий поиск путей и может использоваться для того, чтобы показать игроку, какие из комнат пока недоступны.
Рассеянные и отложенные обновления
В некоторых случаях бывает логично выполнять определённые обновления только частично. Вот несколько примеров:
Некоторые обновления можно выполнять в каждом кадре только для части персонажей, например, скрипты поведения половины пациентов обновляются только в нечётных кадрах, а для второй половины — в чётных (хотя анимации и движение выполняются плавно).
В определённых состояниях, особенно когда персонажи находятся в режиме ожидания, но вызывают затратные части кода (например, сотрудники, проверяющие, что нужно заполнить и ищущие незанятое оборудование), операции выполняются только через определённые промежутки времени, допустим, раз в секунду.
Один из самых затратных, и в то же время распространённых вызовов — проверка того, какие анализы доступны для каждого пациента. При этом нужно оценить множество факторов — например, кто из персонала отделения в данный момент занят и какое оборудование зарезервировано. Кроме того, эта информация не является общей для всех пациентов, потому что на это влияют, например, назначенный им врач и их способность говорить. Необходимо проверять десятки доступных видов анализов, поэтому в одном кадре обновление выполняется только для некоторых, и продолжается в следующем.
Заключение
Оптимизация игры-менеджера со множеством взаимодействующих частей оказалась длительным процессом. Мне регулярно приходилось работать с профилировщиком Unity и устранять самые очевидные проблемы, это стало неотъемлемой частью процесса разработки.
Разумеется, всегда есть возможности для улучшения, но мы вполне довольны результатами. Игра справляется с поставленными нами задачами, а игроки постоянно создают для неё моды, значительно превышая исходное ограничение на количество персонажей.
Стоит также сказать о том, что даже по сравнению с некоторыми AAA-играми, над которыми я работал, в Project Hospital я встретился с самой сложной игровой логикой в своей практике, поэтому многие из проблем были специфичными именно для этого проекта. Тем не менее, я всё равно рекомендую оставлять в любом проекте достаточно времени для оптимизации в соответствии со сложностью игры.