[Перевод] Почему Cities: Skylines 2 так тормозит (часть 1)

ccd050e13301ce3eacbddaf1fd23bf50.png

Одна из самых долгожданных игр для PC, Cities: Skylines 2, вышла в конце октября, но получила смешанные отзывы. По моим ощущениям, геймплейно и с точки зрения симуляции игра сделала шаг в правильном направлении, и по крайней мере, теоретически она выглядит более целостной по функциям, чем первая часть. Однако в ней присутствуют существенные изъяны, от проблем с балансом и спорных дизайнерских решений до багов, из-за которых экономическая симуляция игры становится практически бессмысленной. Стала ли она достойной преемницей оригинала — пока вопрос открытый, но все сходятся в одном: производительность проекта далека от должного уровня.

(Это не) анализ производительности

Тревожные знаки возникали и раньше. Меньше чем за месяц до даты выпуска игры разработчики объявили о том, что рекомендуемые системные требования повышены, а консольный релиз отложен на 2024 год. Довольно большое количество YouTube-игроков и стримеров получило доступ к игре, но им запретили говорить о производительности до снятия обычного эмбарго на обзоры. Это не такая уж исключительная ситуация: за последние недели перед релизом в игры часто добавляют оптимизации производительности и другие исправления, но знак всё равно был нехорошим. Затем, всего за неделю до выпуска Colossal Order опубликовала заявление, которое я бы назвал превентивным извинением за низкую производительность игры. А потом её выпустили.

В играх с глубокой симуляцией наподобие градостроительных симуляторов на удивление сложно добиться хорошей частоты кадров, но выделяет Cities: Skylines 2 на фоне всех остальных проектов то, что в большинстве систем и в большинстве ситуаций она сильно зависит от GPU, а это достаточно необычно для игры такого жанра, потому что в большинстве других основную нагрузку обычно принимает на себя CPU (в том числе и в первой Cities: Skylines), а нагрузка на графическую карту относительно мала. Визуально игра стала качественнее по всем аспектам по сравнению с оригиналом 2015 года, но ничто не оправдывает того, что она тормозит сильнее, чем Cyberpunk 2077 с выкрученными на максимум настройками и трассировкой путей. Лично я бы даже сказал, что C: S2 выглядит довольно некрасиво; хотя модели по отдельности выглядят довольно детализированными, а ощущение масштаба впечатляет, затенение явно осталось от предыдущего поколения, а экран покрыт артефактами рендеринга и плохо отфильтрованными текстурами. Сравнение графики игры с графикой её относительно близкого конкурента Anno 1800 (выпущенного в 2019 году) не идёт ей на пользу. В Anno использовал чуть более стилизованный внешний вид, и по моему скромному мнению, ей удаётся выглядеть более совершенной и целостной, при этом обеспечивая приличную производительность даже на «железе», которое в 2019 году считалось слабым или средним.

A screenshot of a character in Cities: Skylines 2

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

Я не буду тратить время на тщательный бенчмаркинг игры, потому что этим уже занимались многие другие до меня, и сделали это гораздо качественнее, чем получилось бы у меня. Если вам любопытно, изучите статью PC Games Hardware (на немецком) или видео Gamers Nexus (на английском). Вкратце подведу итог: если поднять настройки чуть выше абсолютного минимума (настройка качества графики «очень низкая» полностью отключает такие декадентские излишества, как тени и туман), то для того, чтобы игра работала с частотой 60 кадров в секунду в 1080p, вам понадобится видеокарта за 1000–2000 евро. Для сравнения: на подобном оборудовании в Alan Wake 2 (выпущенной на той же неделе, что и C: S2 и признанной некоторыми самой красивой игрой в текущем поколении консолей) можно достичь примерно таких средних частот кадров со всеми установленными на максимум настройками, включая и трассировку путей, или в 1440p без магии апскейлинга, или в 4K с небольшой помощью со стороны DLSS. Думаю, это хороший показатель того, насколько чрезмерно требовательна C: S2.

A screenshot of a CPU usage graph and utilization percentages for different aspects of hardware.

Игра работает настолько плохо, что Windows Game Bar даже отказался признавать, что у неё вообще есть частота кадров.

В завершение приведу собственные наблюдения: когда я впервые запустил игру на своём относительно мощном игровом PC (оснащённом графической картой NVidia RTX 3080, CPU AMD Ryzen 7 5800X и ультраширокоэкранным монитором 5120×1440), она приветствовала меня частотой кадров ниже 10 FPS в основном меню. Изменив параметры в соответствии с рекомендациями разработчика (включавшими в себя отключение depth of field, motion blur и объёмных эффектов), я достиг FPS почти в 90. Особенно странно это было потому, что в главном меню присутствует лишь статическое фоновое изображение и несколько кнопок. Загрузив пустую карту, я получил примерно 30–40 FPS, и частота кадров оставалась на этом уровне примерно около часа, падая лишь время от времени. Давайте исследуем проблему.

A screenshot of a Cities: Skylines 2’s main menu.

GPU используется на 100%, игра выдаёт 7 кадров в секунду. Изображение урезано с соотношения сторон 32:9.

Проникаем за кулисы

Как и её предшественница, Cities: Skylines 2 написана на Unity, то есть её можно достаточно просто декомпилировать и изучить при помощи любого декомпилятора .NET. Я воспользовался JetBrains dotPeek, имеющим хороший UI в стиле Visual Studio с огромным разнообразием опций поиска и анализа. Однако статический анализ на самом деле не сообщает нам ничего конкретного о производительности рендеринга в игре. Для анализа ситуации с рендерингом я воспользовался Renderdoc — опенсорсным отладчиком графики, помогший мне в некоторых моих предыдущих личных проектах, завязанных на GPU.

Движок и архитектура

Давайте разберём технические основы игры. В Cities: Skylines 2 использована версия Unity 2022.3.7, которой на момент написания статьи всего несколько месяцев. Самым примечательным аспектом Unity 2022 стала стабилизация набора технологий DOTS, над которым Unity работала в течение нескольких лет; похоже, что C: S2 в большой степени построена на этих технологиях, в том числе и на новой реализации Entity Component System (ECS), а также на компиляторе Burst. Я несколько лет изучал архитектуру ECS и экспериментировал с такими реализациями, как Specs, Legion, а недавно и с Bevy, и если бы не множество проблем этой игры, то я лучше бы написал о том, что ECS — это, по сути, идеальная архитектура для подобной игры. Похоже, Cities: Skylines 2 успешно использует DOTS, так как игра задействует ядра CPU гораздо эффективнее, чем её предшественница. К сожалению, многие связанные с графикой проблемы косвенно вызваны применением в игре DOTS. Подробнее об этом ниже.

A screenshot from dotPeek showing different ECS-related types from the game.

Судя по коду, в игре примерно 1200 различных систем, управляющих практически всей игровой логикой.

В игре также используется стороннее промежуточное ПО и несколько изменённых/форкнутых библиотек. В отличие от DOTS, UI Toolkit Unity, очевидно, по-прежнему не готов к уровню продакшена, так как в C: S2 для пользовательских интерфейсов используется Coherent Gameface на основе HTML, CSS и JavaScript. Краткое изучение даёт понять, что в игре используется React и объединение при помощи Webpack. Хотя это гарантированно заставит любого пуриста нативной разработки начать возмущаться и жаловаться на современную молодёжь, лично я думаю, что теоретически это существенно упрощает поддержку и модификацию UI игры. Среди прочих библиотек можно найти InstaLOD, Odin Serializer и файл DLL для NVidia DLSS 3, хотя эта технология пока игрой не поддерживается.

В рендеринге графики игра использует Direct3D 11 и High Definition Rendering Pipeline (HDRP) Unity. Обычная система рендеринга Unity работает только с традиционными игровыми объектами на основе MonoBehaviour, поэтому игре, созданной на основе DOTS и ECS, требуется что-то для восполнения этого пробела. В Unity есть пакет Entities Graphics, но, как ни странно, в Cities: Skylines 2 он, похоже, не применяется. Это может быть вызвано относительной сыростью и ограниченным множеством поддерживаемых функций рендеринга; согласно матрице функций, и скиннинг (используемый для анимированных моделей, например, персонажей), и усечение невидимой геометрии (позволяющее не рендерить то, что скрыто за другими объектами) помечены как экспериментальные, а виртуальное текстурирование (усложняющее работу с текстурами GPU, но повышающее её эффективность) не поддерживается вообще. Похоже, что Colossal Order вместо них решила реализовать «клей» между ECS и рендерером самостоятельно, воспользовавшись BatchRendererGroup и большим количеством достаточно низкоуровневого кода. Ниже я подробнее расскажу об этом и о последствиях такого решения.

Проблемы привязки

Привязка Renderdoc к процессу и сбор событий рендеринга — обычно довольно простая задача. Обычно достаточно лишь указать Renderdoc путь к исполняемому файлу, рабочую папку и аргументы командной строки, после чего Renderdoc запускает двоичный файл и инъецирует себя в процесс игры. Однако моя проблема заключалась в том, что я получил игру по Xbox Game Pass, который выполняет какой-то странный сэндбоксинг и/или странную магию с разрешениями NTFS, чтобы ограничить возможности взаимодействия с файлами игры. Renderdoc не мог считать исполняемый файл игры даже при запуске от имени администратора. Прежде чем я понял, что проблема в Game Pass, я попытался использовать вместо него NVidia Nsight Graphics™️ (инструмент NVidia, похожий на Renderdoc), но у него была та же проблема. В конечном итоге я решил этот вопрос при помощи кредитки: снова купил игру в Steam за полную стоимость, хотя и знал, что она имеет серьёзные проблемы.

Однако версия из Steam тоже не сразу согласилась мне помогать. На этот раз проблема заключалась в Paradox Launcher — небольшом куске bloatware, используемом в большинстве крупнобюджетных игр, издаваемых Paradox. Двоичный файл Paradox Launcher есть и в версии Game Pass, но, по крайней мере, в релизной версии он совершенно не используется. При запуске C: S2 из Steam открывается Paradox Launcher, игрок нажимает на Resume или Play, после чего запускается сам двоичный файл игры. Я попытался привязать Renderdoc, запустив Cities2.exe напрямую, но это не сработало — окно игры создаётся, но спустя несколько секунд открывает Launcher, а затем завершает работу. В Renderdoc есть опция «Capture child processes», которая теоретически позволяет Renderdoc инъецировать себя во все процессы, запущенные целевым процессом, то есть он должен подключиться к Launcher, запускаемому двоичным файлом игры, а затем снова привязаться к двоичному файлу игры, но, я думаю, там есть ещё один слой косвенных связей, который, к сожалению, не позволил этого сделать. Я сконфигурировал Renderdoc так, чтобы он запускал Paradox Launcher напрямую, но это тоже не сработало, так как Steam и Launcher обмениваются информацией о том, какую игру запускать и как обрабатывать аутентификацию/DRM. Частично этот обмен информацией выполняется через аргументы командной строки, которые мне удалось извлечь при помощи Process Explorer, но использование этих аргументов тоже не сработало, поэтому я отказался от такого подхода.

В конечном итоге, мне удалось привязать Renderdoc, воспользовавшись опцией Global Process Hook, которую программа по умолчанию скрывает и не рекомендует использовать. Это очень инвазивный способ привязки, так как он инъецирует DLL в каждый запускаемый в системе процесс, но зато он сработал! Мы наконец-то можем начать разбираться, что же происходит.

Позже мне удалось заставить работать и NVidia Nsight Graphics™️. Вместо того, чтобы пытаться запустить игру или Launcher, я открыл Steam из Nsight, а затем запустил игру из UI Steam, как это делается обычно. В конечном итоге, мне не удалось получить из Nsight существенно больше информации, чем из Renderdoc, поскольку многие функции профилирования и анализа производительности NSight не поддерживаются с D3D11.

Анализ Renderdoc

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

Начнём с анализа следующего кадра:

A screenshot from Cities: Skylines 2 at night

Скриншот Cities: Skylines 2 ночью

Кадр достаточно сложный, но он далёк от масштаба, которого может достичь игра. Захват был сделан в городе примерно с тысячей жителей где-то после часа игры. В кадре присутствует дождь и ночное время суток, но, по моему опыту, ни то, ни другое особо не влияет на производительность. Игра имеет версию 1.0.11f1, то есть в неё включён первый hotfix, выпущенный после релиза. Стоит отметить, что последний на момент публикации патч (1.0.12f1) был выпущен во время написания этой статьи, и в него включены исправления некоторых проблем, которые я опишу ниже, но он решил далеко не все из них.

Renderdoc сообщает, что на рендеринг кадра потребовалось 87,8 миллисекунды, что в среднем даёт нам 11,4 FPS. В время игра работала с частотой 30–40 FPS, так что или этот кадр был выбросом (что, как мы узнали из видео Gamers Nexus, случается достаточно часто), или, что более вероятно, Renderdoc приводит к дополнительной трате ресурсов, влияя на производительность, поскольку все захваченные мной кадры имели чуть более высокое время рендеринга, чем при обычном игровом процессе. Предположу, что даже если Renderdoc повышает нагрузку, это не делает наши измерения совершенно бесполезными, например, отдельные вызовы API становятся в десять раз медленнее, чем обычно.

Для справки: при стабильных 60 FPS время рендеринга кадра всегда должно быть примерно равно1000 / 60 = 16,666... миллисекундам.

Вот основная статистика рендеринга, предоставляемая Renderdoc:

Draw calls: 6705
Dispatch calls: 191
API calls: 53361
Index/vertex bind calls: 8724
Constant bind calls: 25006
Sampler bind calls: 563
Resource bind calls: 13451
Shader set calls: 1252
Blend set calls: 330
Depth/stencil set calls: 301
Rasterization set calls: 576
Resource update calls: 1679
Output set calls: 739
API:Draw/Dispatch call ratio: 7.73796
342 Textures - 3926.25 MB (3924.10 MB over 32x32), 180 RTs - 2327.51 MB.
Avg. tex dimension: 1611.08x2212.36 (2133.47x2984.88 over 32x32)
4144 Buffers - 446.59 MB total 6.48 MB IBs 43.35 MB VBs.
6700.34 MB - Grand total GPU buffer + texture load.

Из одних этих показателей мы мало что можем понять. Кажется, что 6705 вызовов отрисовки и 53361 вызов API — это много, но без контекста затраты на них сложно оценить. Для такой относительно простой сцены 6,7 гигабайта занятой видеопамяти — это довольно много, особенно учитывая, что в нынешнем поколении есть графические карты среднего ценового уровня всего с 8 гигабайтами VRAM.

A screenshot from Renderdoc, showing API calls grouped by rendering pass.

Скриншот из Renderdoc с вызовами API, сгруппированными по проходам рендеринга.

Так как в игре используется HDRP, его документация может служить хорошим фундаментом для понимания проходов рендеринга и вычислений, выполняемых игрой в каждом кадре. Я не буду вдаваться в подробное исследование графики, как это сделано в легендарных разборах DOOM 2016 и GTA V, но поэтапно пройдусь по процессу рендеринга и по ходу дела расскажу о самых интересных вещах.

Обновление данных инстанса DOTS

Почти каждый выполняемый игрой вызов отрисовки использует инстансинг, который необходим для игры такого масштаба. Чтобы инстансинг работал, у игры есть довольно большой единый буфер данных инстансов, содержащий всё необходимое для рендеринга всех объектов. Содержимое и размер данных для каждого инстанса зависит от типа сущности (entity), но, похоже, для обычных игровых объектов наподобие зданий требуется около пятидесяти чисел с плавающей запятой на инстанс, а для дорог существенно больше. Я не разобрался полностью, как управляется буфер, потому что это очень сложная система, но, по сути, данные инстанса каждого видимого объекта в каждом кадре обновляются в буфере, а изменения затем загружаются в GPU. Изначально буфер имеет размер примерно 60 мегабайтов, и при необходимости перераспределяется до большего размера.

Буфер используется практически в каждом выполняемом игрой вызове отрисовки, и согласно данным Renderdoc, он как минимум доступен для каждого вершинного и пиксельного шейдера, однако предположу, что в основном он используется только в вершинных шейдерах. Было бы интересно узнать, как этот буфер влияет на кэш GPU, поскольку, как я предполагаю, инстансы не выстроены в буфере в том же порядке, в котором рендерятся, и для кэширования это может быть проблемой, но мне не хватает опыта, чтобы в этом разобраться. Как бы то ни было, существуют определённые затраты, связанные с поиском данных в этом буфере для каждой вершины, и это может объяснить некоторые из проблем с высокополигональными мешами, о которых я расскажу ниже.

Симуляция

Для симуляций, связанных с графикой (например, для воды, снега, частиц и скелетной анимации), используется множество вычислительных шейдеров. Суммарно они занимают примерно 1,5 миллисекунды, то есть меньше 2% от времени рендеринга кадра.

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

Обновление кэша виртуального текстурирования

Выше я говорил, что виртуальное текстурирование не поддерживается Entities Graphics. Похоже, в C: S2 реализована собственная система виртуального текстурирования/потоковой передачи текстур. Сначала я предположил, что для этого игра использует встроенное решение Unity, но традиционно для Unity, хотя оно было добавлено в движок в 2020 году, вслед за покупкой этой технологии, это решение остаётся экспериментальным и неподдерживаемым (если не сказать большего).

Что же такое виртуальное текстурирование? Я понимаю это так: виртуальное текстурирование — это методика загрузки данных текстур и управления ими потенциально более эффективным для памяти образом, чем традиционная методика применения одной текстуры GPU на каждый текстурный ресурс. Текстуры хранятся в атласах текстур, то есть, по сути, в более сложных версиях листов спрайтов (о которых я рассказывал в своей статье о тайловых картах GPU). Атласы состоят из тайлов фиксированного размера, каждый из тайлов может содержать одну или несколько текстур. Трюк, позволяющий экономить память, заключается в том, что крупные текстуры можно разделить на несколько тайлов, поэтому если у вас есть большая текстура, видимая только в небольшой части экрана, то вам достаточно загрузить только видимые тайлы. Информация о видимости виртуальных текстур создаётся как побочный продукт обычного рендеринга на последующем этапе; информация о видимости используется на стороне CPU для определения того, какие тайлы нужно загрузить, а какие выгрузить. Если вы хотите знать подробности, то подробное описание этой техники можно найти в документации Unreal Engine. Похоже, игра использует виртуальное текстурирование для всех статичных 3D-объектов, за исключением рельефа.

A screenshot from Renderdoc, showing a virtual texture tile atlas.

Один из атласов тайлов, используемых при рендеринге моего примера кадра. Он очень уменьшен, оригинал имеет размер 16368×8448.

Такая методика текстурирования очень изящна в теории, но она вынуждает идти на множество уступок, а реализация игры по-прежнему имеет болезненные проблемы, например, текстуры высокого разрешения иногда отказываются загружаться, даже если поверхность находится близко к камере. Вероятно, использование виртуального текстурирования стало виновником отсутствия в игре поддержки анизотропной фильтрации текстур, — стандартной функции в играх для PC со времён начала 2000-х.

Этот проход занимает примерно 0,5 миллисекунды.

Продолжение следует

© Habrahabr.ru