Менеджер качества, или как не спалить лоу-энд девайсы ультра-графикой
Релиз War Robots состоялся еще в 2014 году, и за 7 лет существования проекта графическая часть в нем постоянно развивалась. Но в то же время команда постоянно сталкивалась с ограничениями из-за минимальных требований к девайсам. Оперируя таким большим проектом, у которого немало устройств входит в low-end сегмент, нельзя просто взять и запилить крутой современный графен, не потеряв при этом часть аудитории.
Так у нас появилась задача: сделать всем красиво и хорошо. Поэтому мы решили делать War Robots Remastered — с блэк-джеком, обновлением графического пайплайна и разделением ассетов на разные качества.
Первое и самое очевидное, что от нас требовалось, — для разных групп девайсов сделать контент, удовлетворяющий требованиям по картинке и производительности.
На тот момент билд War Robots под Android со всеми ресурсам весил порядка 700 МБ и включал сотни единиц контента. У нас было 13 карт, 81 мех, более ста пушек, десяток дронов и еще куча всякой мелочи. Не то, чтобы все это было категорически необходимо в проекте, но если уж начал пилить контент, то иди в своем увлечении до конца.
И второе — нам нужно этими качествами как-то управлять и предоставлять пользователю то качество, которое будет оптимально для его девайса.
У нас уже был менеджер качества, состоящий из динамических пресетов и представляющий собой ScriptableObject с кастомным InspectorGUI. Это была длиннющая портянка с настройками и пресетами, почти полностью завязанная на логику «Роботов», и каждое поле в настройке рисовалось кодом. Хочешь добавить параметр в настройку — не забудь отрисовать это в InspectorGUI. Выглядело это монструозно, так как из-за большого количества настроек число фолдаутов в инспекторе достигало более 9000.
Динамический скейлинг параметров игры давал свои преимущества, но у него также было и несколько минусов, из-за которых нам пришлось от него отказаться. В первую очередь — из-за количества пресетов. Со временем из-за разной архитектуры поддерживаемых устройств количество пресетов качества накапливалось, пока не достигло 15 штук. В реальности мало кто обращал на это внимание и, конечно, никто не тестировал все 15 пресетов на каждом устройстве в продакшене. К тому же, поддерживать такое количество пресетов довольно сложно и с точки зрения разработки: никогда не знаешь, когда старый Quality Manager возьмет и переключит параметры внутри текущего качества — и какие именно. Вдобавок, когда мы начали работу над новым кастомным рендер-движком и на основе него запрофилировали все 15 качеств, оказалось, что разброс по производительности между ними не превышает 15–20%.
Все это нас не устраивало, так что мы решили изменить подход к формированию пресетов и запилить новый Quality Manager. А задачу эту передали нашему отделу кросс-проектной разработки, именуемому Platform Team.
Теперь поговорим о том, что же такое новый Quality Manager в War Robots, какие задачи он решает и как устроен.
Основная задача заключалась в том, чтобы создать инструмент, с помощью которого можно с минимальным временными затратами добавлять в проект настройки качества, формировать из них пресеты и задавать параметры, на основе которых пользователю будет выбран оптимальный для его устройства пресет.
Главными сущностями у нас являются:
- QualitySetting — набор параметров, объединенных в общую группу;
- Preset, состоящий из выбранных уровней QualitySetting разного типа.
Quality Manager состоит из двух частей: runtime-часть с API для инициализации и переключения качеств и editor-часть с GUI, которые позволяют все это конфигурировать.
Начнем с editor-части. Ее задача — предоставление интерфейса для конфигурации настроек качества и пресетов и минимизация количества кода, необходимого со стороны клиента. В идеале мы хотели заставить клиентщиков описывать только структуры настроек качества, а всю работу по отрисовке оставить на стороне Quality Manager.
Вот так выглядит наше окно, разделенное на три вкладки: настройки качества, пресеты, группы устройств:
Давайте подробнее разберемся, как с этим работать, и начнем с вкладки Quality Settings.
Так выглядят классы «базовых настроек» и «настроек кэша пререндеров» в War Robots. Эти же классы затем используются в рантайме:
public class CommonQualitySettings : WRQualitySetting
{
[IntSliderView(0, 72)]
public int CorpsesCount { get; private set; }
[FloatSliderView(5, 600)]
public float UnloadPeriodImGameplay { get; set; }
[FloatSliderView(0, 300)]
public float UnloadPeriodInMenu { get; private set; }
public bool UseMechCacheInHangar { get; set; }
public bool HSEnabled { get; private set; }
public bool BattleAmbientSoundEnabled { get; private set; }
public HangarCacheSettings CacheSettings { get; private set; }
public CommonQualitySettings()
{
CorpsesCount = 12;
UseMechCacheInHangar = true;
HSEnabled = true;
BattleAmbientSoundEnabled = true;
}
}
public class ImageCacheSettings : WRQualitySetting
{
[IntSliderView(0, 1000)]
public int MinCacheSize { get; private set; }
[IntSliderView(0, 1000)]
public int MaxCacheSize { get; private set; }
[IntPopupView(new[] { 128, 256, 512, 1024 })]
public int RenderSize { get; private set; }
public string Info
{
get { return $"Cache takes from {(int) (MinCacheSize * 0.1f)} to {(int) (MaxCacheSize * 0.1f)} Mb"; }
}
public ImageCacheSettings()
{
MinCacheSize = 150;
MaxCacheSize = 200;
RenderSize = 512;
}
}
А вот так это выглядит в окне Quality Manager:
Получается достаточно простая схема. Клиентские разработчики создают класс с набором полей и настраивают уровни качества для него в окне Quality Manager. Он, в свою очередь, «из коробки» умеет отрисовывать примитивные типы, Enum, Nullable, массивы, списки, интерфейсы и собственные классы c полями вышеперечисленных типов, включая другие классы. На случай, когда необходимо отрисовывать для поля кастомный GUI, в QM предусмотрена возможность помечать поля атрибутом, в классе которого реализована отрисовка этого поля.
Так, например, выглядит код класса, меняющего отрисовку для int значения c IntFiled на Slider с параметрами шага, минимального и максимального значений:
[Conditional("UNITY_EDITOR")]
public class IntSliderViewAttribute : CustomPropertyViewAttribute
{
public int MinValue { get; private set; }
public int MaxValue { get; private set; }
public int Step { get; private set; }
public new int Value
{
get { return (int) base.Value; }
set { base.Value = value; }
}
public IntSliderViewAttribute(int minValue, int maxValue, int step = 1)
{
MinValue = minValue;
MaxValue = maxValue;
Step = step;
}
#if UNITY_EDITOR
public override void OnGUI()
{
Value = Step * UnityEditor.EditorGUILayout.IntSlider(Value / Step, MinValue / Step, MaxValue / Step);
}
#endif
}
А так — поле с этим атрибутом:
[IntSliderView(0, 72)]
public int CorpsesCount { get; private set; }
В QM сразу включен ряд реализаций для кастомной отрисовки полей: IntSliderView, FloatSliderView, IntPopupView, PresetIndexView, PresetNameView, QualitySettingIndexView, QualitySettingNameView. Этого скромного набора нам хватило для интеграции QM в War Robots и переезда со старого QM на новый. Кода в проекте стало ощутимо меньше, а тот, который остался, стал заметно проще, понятнее и описывал именно то, что он и должен был описывать — данные и логику работы с ними.
У нас уже есть классы настроек качества и данные для их уровней, так что пора формировать из них пресеты. Для этого отправляемся на первую вкладку окна QM — Presets.
Тут все достаточно тривиально: мы заводим необходимое количество пресетов, задаем им имена и выставляем для них уровни настроек качества. Готово.
Теперь, когда у нас есть пресеты, осталось задать параметры, по которым будет определяться, какой пресет использовать на девайсе.
Третья вкладка — Device Groups.
На этой вкладке мы формируем группы девайсов. Для этого мы используем несколько параметров: объем ОП, частоту процессора, модель GPU и модель девайса для совсем точного попадания. Все параметры не являются обязательными, и можно для группы указать только часть из них. Так, например, для устройств на iOS самый простой вариант — составить карту по модели устройств. На Android же большую часть покроют группы, объеденные по популярным моделям GPU, а в остальных случаях можно указать минимальные требования по объему оперативной памяти и частоте процессора.
Для группы — помимо пресета, который будет выбран по умолчанию — мы также задаем список доступных этой группе устройств пресетов для того, чтобы пользователь на low-end девайсе не смог поменять настройки на ultra high, что может привести к крешам по OOM на старте приложения и блокировать тем самым возможность изменить настройки обратно.
Итак, конфиг QM готов. Сериализуется он в JSON, что дает возможность его легко читать и править без окна QM, а также доставлять на клиент с сервера.
Помимо описанного функционала, QM позволяет:
- работать с несколькими конфигурациями;
- добавлять к пресетам кастомные данные, не относящиеся к уровню настроек качеств (мало ли);
- конфигурировать базовые настройки, не относящиеся к пресетам (раздел Custom Data).
API рантайм-части достаточно простой и включает основные методы для работы с QM:
- инициализация (в том числе и обновление текущего инстанса QM из нового конфига);
- выбор пресета (по индексу, имени, объекту пресета из конфига);
- выбор уровня настройки качества (по индексу, имени, объекту настройки качества из конфига);
- сброс пресета на дефолтное значение;
- сохранение/удаление данных о выбранном пресете и уровне настроек качества (стейта).
Так инициализируется инстанс QM:
var exampleStateController = new ExampleStateController();
var qualityManager = QualityManager.Initialize(exampleStateController);
Помимо основных методов, инстанс содержит ивенты о смене пресета и уровня настройки качества. Логика применения настроек качества лежит на клиентской стороне — для этого у класса QualitySetting есть виртуальный метод Apply, который вызывается при смене уровня настройки качества.
Помимо базовых методов API, в QM есть набор методов для «мягкого» переключения уровней качества. В случае, когда в рантайме мы сталкиваемся с ситуацией, при которой девайс должен тянуть заданный ему пресет, но при этом происходит падение FPS из-за посторонних факторов, можно ослабить нагрузку, понизив одну из настроек качества, или наоборот. Для этого в QM есть отдельный тип настроек качества, у которого есть виртуальный метод CanBeSwitchedTo, позволяющий определить, можно ли сменить текущий уровень настройки на новый. Так мы можем в рантайме поэтапно даунскейлить качество, чтобы стабилизировать FPS, или наоборот — попробовать дать девайсу нарисовать лучшую картинку, пока не начнем ловить падение FPS.
Для этого API QM содержит набор методов для попытки даунгрейда или апгрейда уровня настроек качества. При этом можно задать как конкретный тип настройки качества, так и предоставить системе самой решать, какую из них менять. В этом случае система выберет настройку, приводящую к минимальному изменению общего качества, и будет выбирать настройки с большим количеством уровней качества.
При смене пресета/уровня настройки качества QM сохраняет стейт и при последующей инициализации использует уже его. По умолчанию данные сохраняются в PrefsManager, и сохраненный стейт используется до тех пор, пока не будет изменена версия конфига QM. При необходимости можно реализовать свой вариант IStateController и использовать эту реализацию при инициализации QM, чтобы определить, когда можно использовать стейт от старой версии конфига, а когда смена конфига должна приводить к сбросу пресета на дефолтный.
Конфиг для QM лежит в папке ресурсов и по умолчанию грузится из билда. Дополнительно к этому мы предусмотрели механизм удаленного обновления конфига с CDN. Для этого файл конфигурации выкладывается на CDN, а на клиент приходит ссылка на него с указанием хеша конфига. По хешу система определяет, нужно ли ей обновить конфиг перед инициализацией или на девайсе уже есть актуальная версия конфига. Так мы в любой момент при необходимости можем поменять настройки QM у всех пользователей.
С QM разобрались. Давайте теперь более предметно рассмотрим, как мы сформировали список настроек качества, пресетов и групп устройств на проекте War Robots.
Мы решили зафиксировать несколько четко установленных пресетов, а динамики достигать так же, как и в консольных играх, — за счет скейлинга картинки. Современные девайсы обладают экранами с высоким разрешением, однако GPU в них стоят, конечно, далеко не RTX 3090, так что было бы наивно полагать, что они будут справляться с 60 FPS в нативных Quad HD или даже 4k. Собственно, мы сразу ограничили плотность пикселей сверху, проитерировавшись до значения в 350 ppi.
Изначально при работе над ремастером мы фокусировались на двух качествах — HD (high definition) и LD (low definition). Весь контент, который мы переделывали, основывался на них, и все инструменты по автоматической генерации исходили тоже из них. Однако вскоре мы поняли, что нам понадобится дополнительный уровень качества, который будет нацелен на устройства с небольшим, по нашим меркам, объемом RAM. Так родился еще один пресет качества — ULD (ultra low definition).
Так выглядит игра в качестве HD:
Так — в LD:
А так — в ULD:
Каждый пресет мы разграничили не только по качеству, но и по максимально возможному FPS. Сейчас мы позволяем выбирать некоторым устройствам 60 FPS, что достигается благодаря отдельной настройке внутри нового QM:
Как мы видим, каждый пресет имеет настройку TargetFPSQualitySettings и внутри нее два уровня, отвечающие за максимально возможный FPS на устройстве. Затем «глобальные» пресеты качеств также разделяются: например, есть качество LD, а есть LD60. Это значит, что пользователи, устройства которых попадают в группу LD60 (по названию устройства — на iOS или по GPU — на Android), получают возможность в настройках включить 60 FPS:
Какое-то время мы рассуждали, нужно ли включать пользователям 60 FPS по умолчанию, но пришли к тому, что не стоит: это значительно повысит использование батареи мобильного устройства, а по нашим внутренним данным на разных проектах на такую частоту кадров переключаются 5–15% аудитории (у которой эта настройка вообще доступна).
Также внутри QM содержится важнейший параметр — с каким «тэгом» ресурсной системы работать:
Так, для ULD качества используется тег LD_ULD, который содержит набор ресурсов, упакованных нашей ресурсной системой для этих качеств. Объединение этих двух качеств дало нам большую экономию на дубликатах ресурсов, которые складываются в Asset Bundles —, но это, я думаю, мы расскажем в наших следующих статьях.
Таким вот образом «собирается» каждое качество: это всего лишь набор уровней настроек.
Для того, чтобы применить настройки в рантайме, используется система наследования классов QM. Разберем пример применения настроек рендера:
Как пример, для ULD настройки рендера используется MasterTextureLimit = 1. Давайте посмотрим, как мы можем применить его к нашей игре.
Каждая настройка должна переопределять абстрактный класс WRQualitySetting, что и делает наш пользовательский класс RenderingQualitySetting:
public class RenderingQualitySettings : WRQualitySetting
{
public RenderingPipelineAssetType RenderingPipelineAssetType { get; private set; }
public RenderingPipelineSetting RenderingPipelineSetting { get; private set; }
public int MasterTextureLimit { get; set; }
public MsaaQuality MSAA { get; set; }
// … some code … //
}
Благодаря этому наш класс может перегружать метод Apply:
public override void Apply()
{
base.Apply();
// … some code … //
// We don't want to switch MasterTextureQuality when it is set to 0 and the new quality is
// also using 0 (so i.e. HD -> LD or LD -> HD)
// So effectively we are only doing the switch when the MasterTextureQuality really changes.
// If MasterTextureLimit is > 0 then we switch in any way
if (MasterTextureLimit > 0)
{
UnityEngine.QualitySettings.masterTextureLimit = MasterTextureLimit;
}
// … some code … //
}
Собственно, при вызове этого метода мы можем делать что угодно. В этот момент мы знаем, что наши ресурсы загружены, а рендер-пайплайн уже готов к работе.
Вызов метода Apply происходит в двух случаях:
- инициализация игры — в этот момент мы поднимаем с диска пресет качества, который использует клиент, и применяем его;
- переключение качества в настройках проекта — в этом случае после ряда проверок контроллер окна вызывает незамысловатый код:
private void OnConfirmPopupButtonClick()
{
var supportedPresetData = _supportedPresetsData.Find(x => x.IndexInUiPresetsLists == _selectedPresetIndex.Value);
AnalyticsUtils.QualityPresetChanged(supportedPresetData.QmPreset.Name);
ApplicationContext.QualityService.QualityManager.SetCurrentPreset(supportedPresetData.QmPreset);
// ... reload game … //
}
Вызов API QualityManager ApplicationContext.QualityService.QualityManager.SetCurrentPreset вызовет, в свою очередь, череду изменений внутри конфигураций и в конце переключит и применит все настройки, которые были зарегистрированы в QM.
Наверное, один из самых важных этапов, который был произведен перед переходом на новый QM — это чистка старых параметров качества.
За долгий срок разработки проекта скопилось приличное количество давно неиспользуемых полей или тех, которые имеют минимальное значение для производительности, а также пресетов качества, которые были созданы под устройства, которые мы давно не поддерживаем.
Мы смогли выделить основные необходимые для нас параметры, которыми хотелось управлять, а также разделили их на условные группы:
Стоит отметить также, как раньше происходил выбор качества, которое нужно выставить на устройстве. Был большой CS-файл, в котором кодом описывались характеристики нужных устройств. На первый взгляд, такой подход может показаться наивным и не гибким, однако у него есть свои плюсы.
На практике довольно сложно законфигурировать все устройства идеально, и бывают ситуации, когда на разных устройствах с одинаковым, казалось бы, SoC, игра ведет себя совершенно по-разному. Могут быть некачественные детали (например, дешевая память с низкими характеристиками) или некорректно написанные драйверы. В этом случае девайс всегда можно выделить отдельно и подобрать настройки под него. При создании QM мы учли подобные случаи, и мы можем производить конфигурацию пресетов не только per-GPU, но и per-device (это активно используется, например, на Apple-устройствах).
Отдельно стоит отметить, что у нас имеется возможность как хранить манифест QM внутри игры, так и на CDN, и доставлять его в клиент динамически на старте. Определение наиболее актуального происходит простым определением наличия ссылки на нашем мета сервере. Если она есть, то конфиг всегда берется с сервера. Также на мета-сервере имеется возможность разделять конфиги по версиям клиента, поскольку у нас бесшовные обновления, и несколько версий клиента живут в проде одновременно.
Новый Quality Manager дал нам довольно большие возможности: это и мощность управления конфигурациями из старой системы, и простота тестирования, и возможность менять параметры буквально на лету через сервер, и упрощение разработки графического пайплайна. Также QM удобным образом позволил нам разделить настройки качества и выдать хорошую графику на телефонах, которые ее поддерживают, при этом сохранив приближенную к старой на слабеньких устройствах в таком же FPS, как в оригинальной игре.
Авторы материала: Дмитрий Самсонов, Senior Platform Developer, Павел Зинов, Head of Client Department