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

Для анализа взята релизная версия (1.0.0) с рендером Vulkan (который разработчики позиционируют как основной), GPU Capture сделана при помощи Nvidia Nsight, разрешение экрана 2560×1440.

Вступление

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

У нас есть 5 основных этапа вызова отрисовки для opaque (непрозрачных) объектов: привязка меша, вершинный шейдер, сборка примтивов + растеризатор, тест глубины и стенсила, пиксельный шейдер. Без лодов (т.е. без привязки другого меша на первом этапе), вершинный шейдер и сборка примитивов будут одинаково работать, занимай ваш объект хоть весь экран, хоть один пиксель. Растеризатор превращает информацию треугольников в пиксели, и здесь я остановлюсь поподробнее.

Когда растеризуется меш, растеризатор гарантирует, что один конечный пиксель будет соответствовать одному семплу одного треугольника. Для этого производится тест покрытия: берётся точка (семпл) (в данной ситуации центре пикселя, но в зависимости от MSAA точек может быть несколько в разных местах), и если она находится внутри треугольника, то треугольник проходит тест, и его данные берутся для пиксельного шейдера. Если несколько треугольников проходят тест, то берётся тот, который ближе к камере (имеет меньшую глубину). Если семпл приходится на грань между треугольниками, то выбирается левый или верхний треугольник относительно точки (top-left fill rule). При этом, растеризатор не знает ничего про другие меши в других вызоывах отрисовки, так что вся эта работа может быть впустую, если в дальнейшем это место перекроет другой меш.

После растеризатора происходит тест глубины, который сравнивает, ближе ли текущий пиксель к камере, чем значения из буфера (возможен более ранний тест глубины, но в EU5 его нет). Если ближе, значит этот пиксель виден, запускается пиксельный шейдер, который перезаписывает буфер цвета. При этом, пиксельный шейдер запускается не просто ради одного пикселя, а для блока 2х2. Это связанно с тем, как шейдер определяет, какой лод текстуры использовать: при помощи операций ddx() и ddy(), которые возвращают частную производную (дельту) переданной переменной по отношению к соседнему пикселю. Т.е., если вызвать ddx(x), когда у текущего пикселя x = 0.1, а у соеднего 0.4, ddx вернёт разницу 0.3. Понимая, как быстро меняются uv координаты треугольника, можно вычислить, текстура какого размера будет совпадать по своей плотности пикселей с необходимым колличеством пикселей на экране. При этом есть несколько нюансов:

  1. мы не можем выбрать с каким конкретно соседом сравнивать значение. Если ddx вызывает верхний правый шейдер в блоке 2x2, то вернётся дельта из верхнего левого (в некоторых реализациях при горизонтальной проверке сравнивается верхний и нижний ряд, а их результат усредняется).

  2. в ситуации с одним пикселем на треугольник, результат операции ddx может давать некорректные значения. Поэтому, из-за увелечения треугольников на экране, качество изображения может ухудшаться, а не улучшаться.

  3. если растеризированный треугольник состоит только из одного пикселя, то мы всё равно вызовем пиксельный шейдер 4 раза, и отбросим лишние результаты. Поэтому стоимость отрисовки треугольника из одного пикселя может быть равна стоимости отрисовки треугольника из 3 пикселей.

Детальная 3D-карта

Анализируемый кадр
Анализируемый кадр

Сначала идёт процесс отрисовки теней. В нём нет ничего особенно интересного, за исключением того, что даже такие маленькие объекты, вроде забора огорода или бобров, являются шадоукастерами даже на большом расстоянии. Возможно, на более близком зуме их можно разглядеть, однако тут это будет невозможно. Также, можно было бы плоские участки земли исключать из шадоупаса (т. к. тень от неё в принципе никуда не может упасть). Земля занимает значительное колличесто пикселей шадоумапы, которые по сути рассчитались просто так. Можно сделать предположение, что в шадоупас просто забита вся возможная геометрия в видимой части экрана.

Тень от забора огорода
Тень от забора огорода
Отрисовка террейна
Отрисовка террейна

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

Вместо разделения террейна на биомы или регионы, парадоксы пошли по пути убершейдера, который применяется везде. Последствия этого очевидны: вместо специализированного набора данных для каждого региона, мы всегда держим в памяти данные всего мира. Данный шейдер использует 145 (!) текстур слоёв террейна. Они имеют формат RGBA_BC3_UNORM_SRGB или RGBA_BC3_UNORM (для нормалей) с разрешением 512х512. В основном это диффузные текстуры + нормали. При этом, прямо скажем, не все эти текстуры блистают детализацией. К примеру:

Едва различимые градиенты земли.
Едва различимые градиенты земли.

Если взглянуть на самый большой буфер pdx_hlsl_cbPdxTerrain2MaterialConstants 55.3 KB, то можно увидеть индексы для 69 материалов, после которых идут какие-то константы материалов для 199 биомов, в некоторых биомах могут быть индексы до 15 материалов.

После текстур материалов идёт ряд глобальных текстур, которые должны быть общими вне зависимости от биома. Самая первая из них, ProvinceColorIndirectionTexture_Texture, имеет формат R8G8_UNORM с разрешением 16384×8192. Одна только эта текстура занимает 256mb VRAM.

ProvinceColorIndirectionTexture_Texture
ProvinceColorIndirectionTexture_Texture

Ещё один пример нерационального использования VRAM: текстура VirtualHeightmapPhysicalTexture, является атлассом текстур высот. Однако, даже половина его не заполнена. R16_UNORM 8192×8192 занимает 128mb.

VirtualHeightmapPhysicalTexture
VirtualHeightmapPhysicalTexture

Суммарно все «глобальные» текстуры занимают 838.336mb. Шейдер занимает 4253 SPIR-V кода или 1282 GLSL.

Меши рек имеют ~2 до ~4 тысяч вершин, среди которой есть много лишней геометрии, особенно на прямых.

Отрисовка рек
Отрисовка рек
Пример излишней геометрии
Пример излишней геометрии

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

Lod0
Lod0
Lod1
Lod1
Занимаемые пиксели домами. Излишняя геометрия добавляет шум в конечное изображение. Если дом по центру ещё можно как-то различить, то городская площадь левее превращается в набор цветных клякс.
Занимаемые пиксели домами. Излишняя геометрия добавляет шум в конечное изображение. Если дом по центру ещё можно как-то различить, то городская площадь левее превращается в набор цветных клякс.

Самые крупные вызовы отрисовки достались деревьям. Во первых, они имеют большой фрагментный шейдер (SPIRV 2578 строк или GLSL 779), в который, например, входит логика для наложения тумана войны. То есть, вместо специального шейдера для тумана, каждый объект на карте индивидуально для себя рассчитывает наложение тумана.

Меш деревьев
Меш деревьев

Первый вызов отрисовки деревьев: vkCmdDrawIndexed() с instanceCount 1979. Однако, этот вызов рисует только часть деревьев.

Суммарно, на отрисовку всех деревьев тратится 11 вызовов отрисовки (это ещё ~половина экрана без леса), в каждом из которых от ~2 до ~4 тысяч мешей, каждый меш это 5-7 квадов. Вы уже можете догадаться что это значит.

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

Также, тут стоит упомянуть, что в качестве дизера используется Bayer4x4, который к тому-же статичен от кадра к кадру, что не позволяет сгладить ошибку за счёт накопления информации между кадрами. Он обладает очень низким качеством, и паттерн очень чётко виден, если вы поставите DLSS в режим производительности.

Bayer4x4
Bayer4x4
Сравнение дизера Bayer4x4 vs Rdither vs IGN. Код лежит на shadertoy, там же можно взглянуть на динамический вариант. Подробнее про дизер можно почитать тут.
Сравнение дизера Bayer4x4 vs Rdither vs IGN. Код лежит на shadertoy, там же можно взглянуть на динамический вариант. Подробнее про дизер можно почитать тут.

После отрисовки основного массива деревьев рисуются различные животные. Олени, лоси, коровы, овцы, волки и... бобры?

Прекрасный в своей детализации бобр
Прекрасный в своей детализации бобр
Бобра видите? Нет? А он есть.
Бобра видите? Нет? А он есть.

Напоминаю, что даже если пиксельный шейдер полностью скипнут тестом глубины, перед этим вершинный шейдер всё равно должен отработать для всех вершин меша. Кроме стандартных матриц трансформации, данный вертексный шейдер также читает две текстуры высоты, текстуру шума и тумана войны. И да, бобр к тому же ещё и анимирован. Это даёт около 256 строк GLSL кода или 805 SPIR-V.

Спойлер

Вот он, бобр, занимает неполных 11х4 пикселей. Для этого используется 485 вершин, то есть более 10 вершин на пиксель. Если честно, это уже начинает напонимать другой проект Paradox Interactive — Cities Skylines 2, где у людей были детализированные зубы во рту.

Глобальная карта

В целом, глобальная карта работает довольно быстро, так что претензии в этом разделе будут менее значительными с точки зрения производительности.

Анализируемый кадр
Анализируемый кадр

Начало сразу многообещающее, относительно небольшой шейдер (447 GLSL, 1625 SPIR-V), большая часть визуала отрисована одним квадом. Это очень хороший результат, учитывая какой процент пикселей сразу принимают конечный вид.

Дальше идут сотни вызовов отрисовки текста, по одному на название страны. После них начинают рисоваться границы, по мешу на страну.

До отрисовки меша границ Валахии. Обратите внимание, что шейдер карты уже нарисовал границы слегка более тёмным цветом.
До отрисовки меша границ Валахии. Обратите внимание, что шейдер карты уже нарисовал границы слегка более тёмным цветом.
После отрисовки меша границ Валахии.
После отрисовки меша границ Валахии.

Вершины мешей границ лежат в нескольких больших буферах, поэтому буду приводить цифры треугольников: 4 621 треугольник у Валахии. В режиме wireframe в чёрных областях заметна излишняя геометрия. Можно сказать, что этот меш сделан с приличным запасом для приближения.

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

Даже европейский малыш, который занимает 5х7 пикселей может похвастаться 353 треугольниками.

При этом, граница это место между территориями двух (или более) государств. Так что пиксели границ в среднем рисуются два и более раз.

Два вызова vkCmdDrawIndexed - две отрисовки меша границ.
Два вызова vkCmdDrawIndexed - две отрисовки меша границ.

Вернёмся к шейдеру карты. Как он нарисовал границы? При помощи текстуры BorderDistanceFieldTexture_Texture R8_UNORM 4096x2048 (8mb VRAM). SDF текстуры (поля дистанций) являются хорошим выбором для отрисовки различных чётких линий, т.к. дистанция правильно интерпалируется между пикселями.

BorderDistanceFieldTexture_Texture
BorderDistanceFieldTexture_Texture

Разрешения этой текстуры, конечно, начинает не хватать в области священной римской империи.

Поэтому меня возникает вопрос, почему бы не разделить мир на 8 регионов, каждому из них не назначить по одной такой SDF текстуре? Вершинные буферы в данной ситуации занимают больше VRAM, мне удалось насчитать ~76.84 MB + ~4.8mb буфера индексов. При этом, дело не только в памяти, но и в колличестве вызовов отрисовки и использованных треугольниках. Чисто механически, видеокарта может совершать в вершинных шейдерах столько же работы, что и в пиксельных. Самое узкое бутылочное горлышко в растеризаторе и сборке примитивов, видеокарты просто не рассчитаны на слишком большое колличество треугольников. Так что, вариант с несколькими SDF текстурами займёт меньше памяти и ресурсов.

Вывод

Студия последовательно шла по пути упрошения логики рендера ради ускорения разработки. Это видно по одному большому убершейдеру со всеми данными мира, вместо набора специализированных шейдеров и данных, по отсутствию правильного порядка вызовов отрисовки, по неконтролируемому овердроу, по примитивному алгоритму извлечения мешей из линий (границы и реки), по частично сломанной системе LOD и по отсутсвию ограничений шадоукастеров.

Последствия очевидны и измеримы. Хронически низкая эффективность пиксельных шейдеров из-за излишней геометрии, большие требования к видео памяти, тяжёлые пиксельные шейдеры без защиты от overdraw, вершинные шейдеры выполняются даже для крошечных или невидимых на экране объектов. Платить за это приходится производительностью в реальном времени.

Есть и плюсы. Режим политической карты ощутимо легче географического по затратам. Переходы между режимами глобальной карты выглядят хорошо. Технически наибольший эффект для производительности даст возврат к более специализированным шейдерам, сортировка непрозрачных вызовов front-to-back, исправление LOD и агрессивная фильтрация/ограничение мелких шадоукастеров.

Если у вас слабое железо, советую избегать детальной карты, особенно леса.

Комментарии (8)


  1. kompilainenn2
    12.11.2025 17:28

    Эй там, наверху, редакторы Хабра! А можно таких статей побольше?


  1. Jijiki
    12.11.2025 17:28

    спасибо за обзор, вы взялись сразу за обзор Вулкана, но если подходить со стороны Opengl4.6 то мы подходим к теме частиц, потомучто треугольник можно создать на конвеере геометрического шейдера отсюда тема переходит в эту - VK_EXT_transform_feedback.html glBeginTransformFeedback.xhtml

    тоесть надо кинуть в новый конвеер просто сетап текущей карты и новый конвеер поидее теоретически сможет отрисовать карту такую, это же только карта

    там можно подумать поидее как оптимизировать, конечно это теоретически, но с частицами 2000000 работает имейте ввиду

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

    тоесть карту придётся подготовить - смотря какая она, может она поделена на квадраты и представлена в виде картинки

    хотя, сложно походу это вся игра в таком виде, я думал у какой-то 3д игры, есть режим просмотра карты и он нагружает )


  1. Wolf4D
    12.11.2025 17:28

    Ого, статья прямо в тему! Только сегодня занимался оптимизацией графики в своей игре, и как раз натолкнулся на проблему сродни описанной - много деревьев рисуются instanced плейнами, наблюдаю немалую такую просадку производительности. Вероятно, лютый overdraw. Могли бы подсказать, какие пути задействовать, чтобы его побороть?


    1. tbl
      12.11.2025 17:28

      в godot предлагают идти по этому плану: LOD -> Billboards/Imposters -> MultiMesh

      https://docs.godotengine.org/en/4.3/tutorials/performance/optimizing_3d_performance.html#level-of-detail-lod


    1. Jijiki
      12.11.2025 17:28

      а сколько деревьев? инстансингом по количеству, я рисовал 100 000 анимаций 1го адреса, была просадка маленькая, с деревьями, мммм, вживить в террейн наверно запомнить координаты, там еще зависит от подхода, и используете или нет ускорение, странно у меня и террейн много вершин имеет, нет просадок какие были раньше, ни на убунте ни на генту-база дистрибутиве, я вроде уже без вживления разобрался как делать, ну по кубам был предел 1 миллион, если у вас деревья на 2д плейне можно в геометрический конвеер забросить

      у меня всё в деревьях, на столкновение слой(это ригид самописный, и BVH), и обьекты на отрисовку другое дерево(или универсальное бинарное дерево или просто бинарное дерево), я просто пуляю в мейн шейдер обьекты, или выбираю анимацию, и щас вот 2000000 частиц одолел систему на геометрическом

      может быть в купе с подходом зависит от подхода вулкан/опенжль/движок/язык, может движок pso там что-то шаманит, если это анриал, на сурсе это проще сводить даже без глобал илюминейт что-то получается кстати

      потом по данным, еще надо смотреть, нужен редактор где видно размер данных структур, чтобы структуры обьектов были идентичны - аля 1 структура на всю геометрию почти кроме света и других нюансов

      и если есть интерес на скорость, поменьше реактива(можно начать без ООП оставив деструкторы там где надо), эти структуры просто хранят данные

      отсюда следует плотная работа с адресацией, я например использую указатели, у меня получилось снять с модели половину памяти, сохраняя и ригид и модель

      и да пока ригид я не обновляю фактически, но в дереве он фиксируется, и еще нюанс, частички с геометрического можно тоже в ригид закинуть, кароче у меня уже не как в блендере работает


    1. 5a5ha Автор
      12.11.2025 17:28

      Самое простое: препасс глубины.

      Перед отрисовкой деревьев бери только буфер глубины как рендертаргет, отрисовывай деревья без пиксельного шейдера (только пиши глубину, либо с минимальным пикс шейдером, если есть клиппинг), затем бери в рендрертагрет свой колор буфер и устанавливай тот же буфер глубины без очистки (чтобы информация о ближайшей глубине сохранилась), после чего в пиксельном шейдере деревьев ставь Depth Test LEqual.

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


  1. ArkadiyMak
    12.11.2025 17:28

    Также, можно было бы плоские участки земли исключать из шадоупаса (т. к. тень от неё в принципе никуда не может упасть). Земля занимает значительное колличесто пикселей шадоумапы, которые по сути рассчитались просто так.

    Может, там VSM или ESM тени? Им нужно, чтобы ресиверы присутствовали в шадоумапе, иначе тени будут с артефактами.

    (возможен более ранний тест глубины, но в EU5 его нет)

    Разве он не всегда присутствует по умолчанию и отключается в случаях, когда, например, в пиксельном шейдере есть discard или запись в gl_FragDepth?


    1. 5a5ha Автор
      12.11.2025 17:28

      На счёт VSM, буфер глубины теней там стандартный, без экспоненты.

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