Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает

Давайте немного разберемся с процессами оптимизации графики, понятием Draw Call и зачем это все вообще нужно. Простыми словами это запрос движка к железу на отрисовку кадра. Это делается при помощи API. Главная задача оптимизации, это сокращение времени на отрисовку каждого кадра и снижение нагрузки. Время - это важнейший ресурс оптимизации.

Что такое API

Зайдем издалека. API (Application Programming Interface) - предоставляет разработчикам аппаратного и про­граммного обеспечения средства создания драйверов и программ, работающих быстро и на боль­шом числе платформ.

Примеры наиболее распространенных сейчас API, которые необходимы для работы с играми, это OpenGL, DirectX, Direct3D, Vulkan. На деле видов API несколько больше. В целом они выполняют одинаковые функции, но при этом имеют различные инструментарии и их рабочие процессы различаются на разных уровнях, также они могут быть созданы под разные цели и задачи, но это давайте оставим программистам и инженерам.

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

В конечном итоге это приводит нас к известному и важному параметру производительности - FPS (frames per second) - количество кадров в секунду на экране монитора или телевизора. По сути, этот параметр является конечным результатом работы движка с API.

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

Конвейер рендеринга

Чтобы финально отобразить на экране модель, надо прогнать ее через видеопроцессор (далее GPU) для обработки данных и последующего вывода. Если обобщить, то этот путь достаточно прост: меш загружается, вертексы группируются в треугольники, треугольники преобразуются в пиксели, каждому пикселю присваивается цвет, и конечное изображение готово. Давайте рассмотрим чуть детальнее.

После экспорта меша из 3D-редактора, геометрия загружается в движок игры двумя наборами параметров:

1. Буфер вершин (Vertex Buffer, VB) - содержит список вертексов меша и их свойства (положение, UV-координаты, нормаль, цвет и т.д.).

2. Буфер индексов (Index Buffer, IB) - список вершин из VB, соединённых в треугольники. То есть, мы можем сделать вывод что треугольник - это по сути индекс для групп вертексов.

Как видите, пока все вполне понятно, вам знакомы написанные выше понятия и вы, как 3D-художники, постоянно работаете с ними.

Помимо буферов геометрии, мешу также назначается материал, определяющий внешний вид и его поведение (например, реакция на освещение). Для GPU этот материал становится шейдером - специально написанной программой, определяющей способ обработки вертексов и цвет конечных пикселов. При создании материала для меша нужно настраивать различные параметры, например значение базового цвета, или подключать текстурные карты: albedo, roughness, normal map и т.д. Все они применяются шейдерами как входные данные. А сам процесс работы шейдера называется шейдинг.

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

Давайте рассмотрим конвейер рендеринга на наглядной схеме и немного разберемся в этапах этого процесса.

Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает

- Input assembly (входная сборка) - GPU получает из памяти буферы вертексов и индексов, определяет как образованы треугольники и какие данные присвоены вертексам.
- Vertex shading (затенение вертексов) - вертексный шейдер выполняется для каждого из вертексов меша, обрабатывая по отдельному вертексу за раз. Его задача - преобразовать вертекс, получить его положение в пространстве и, применив текущее положение камеры, вычислить его положение на экране (целевом рендере).
- Rasterization (растеризация) - после обработки всех вертексов и получения их координат на экране, треугольники меша растеризируются - преобразуются в наборы пикселов. Значения каждого вертекса - UV-координаты, цвет вершины, нормали и тд. - интерполируются по пикселям треугольника, то есть растягиваются с усреднением. Пример: если один вертекс треугольника имеет черный цвет, а другой - белый, то пиксель находящийся между ними получит серый цвет. Также на этом этапе выполняется ряд программных тестов проверяющих корректность растеризации.
- Pixel shading (затенение пикселов) - далее для каждого растерезированного пикселя выполняется пиксельный шейдер (технически, на этом этапе, это еще не пиксель а фрагмент, по этому иногда пиксельный шейдер называют фрагментным). Суть этого шейдера в том, чтобы задать пикселю цвет посредством сочетания свойств материала, текстур, источников освещения и прочих вводных параметров. Это позволяет привести пикселы к окончательному виду. Пикселов может быть очень много, например на целевом рендере разрешением 1080р их будет порядка двух млн., и каждый из них должен обработаться шейдером минимум один раз. Этот процесс является достаточно громоздким и продолжительным для GPU.
- Render target output (вывод целевого рендера) - финальный пиксель снова проходит ряд программных проверок, и если все отработало корректно, то он записывается в целевой рендер хранящийся в памяти.

Да, выглядит несколько громоздко и на деле это процесс значительно сложнее и имеет гораздо больше промежуточных этапов. Но цель этой схемы - просто помочь вам представить себе общий путь от вашего меша к финальной растровой картинке на экране. Один из главных посылов этой схемы - составные элементы модели имеют прямое отношение к тому, что и как ваш графический процессор обрабатывает и интерпретирует, и насколько быстро он это делает. Именно поэтому вам надо знать из чего состоит 3D модель, и понимать как работают эти элементы.

Draw Calls

Собственно, пора коснуться темы запросов на отрисовку и понять, как текст выше с этим связан.

Сам по себе GPU не может просто взять, и отработать процесс рендеринга, так как он зависим от кода игры, который обрабатывается центральным процессором (далее CPU). Именно CPU сообщает ему, что и как надо рендерить. CPU и GPU, это отдельные микросхемы, которые работают независимо и параллельно. Чтобы получить необходимую (оптимальную) частоту кадров, они оба должны выполнить всю работу по созданию кадра за определенное время. Например для 30 fps необходимо 33 миллисекунды на один кадр, очень маленькое значение, не так ли? Рассмотрим на этом примере.

Чтобы этот процесс работал правильно, кадры выстраиваются в очередной конвейер - CPU занимает для своей работы весь кадр и занимается обработкой ИИ, физики, анимаций и тд.. Далее, в конце кадра, он отправляет сигнал GPU что тот может начинать работу в следующем кадре. Это дает каждому процессору нужное количество времени для выполнения работы, но цена этого - задержка длиной в кадр, называемая латентность. По сути, первый шаг всегда будет со стороны CPU, так как именно ему надо сформировать запрос на первый кадр. Этот запрос на рендер и есть тот самый Draw call. Каждые 33 мс (в примере с 30fps) конечный целевой рендер копируется и отображается на экране во время VSync (вертикальной синхронизации). Также данные этого рендера могут примениться в следующем рендере.

Вертикальная синхронизация (VSync) — синхронизация кадровой частоты с частотой вертикальной развёртки монитора. Наиболее часто данная настройка используется конечным пользователем в компьютерных играх, однако может применяться при настройке графики в приложениях где требуется стабильная частота обновления кадров. По сути это интервал, в течение которого готовится новый кадр.

Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает

Общая схема конвейера между CPU и GPU. На ней мы можем увидеть тот самый Draw call, ради которого все затевалось.

Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает

На этой схеме видно, как на втором кадре CPU не попал в заданное время и отправил draw call уже в третьем кадре, соответственно GPU опоздал с рендерингом и произошла потеря четвертого кадра. FPS просел, образовалась задержка. В случае с задержкой со стороны GPU будет, по сути, та же ситуация - если он не уложится в заданный промежуток, то не сможет вовремя обработать draw call и это приведет к пропуску кадров.

По факту на эту тему можно говорить бесконечно, и надо быть программистом специализирующемся на графике, чтобы понимать все нюансы этих процессов. По сути процесс вывода игры на экран можно сравнить с парным забегом на дистанции - задача двух участников (CPU и GPU) - это выполнять забег слаженно, а draw call в этом забеге является эстафетной палочкой, которую нужно передать и принять в нужное время и в нужном месте. Процессы оптимизации направлены как раз на то, чтобы этот процесс не нарушался. Также очевидно, что мощное железо это залог того, что все запросы будут отправляться и отрабатываться своевременно.

Итак. Draw Call - это последовательность команд, которые сообщают GPU как ему надо отработать рендер. В процессе прохода draw call по конвейеру GPU, он использует различные параметры заложенные в запрос, для определения того, как рендерится меш/меши/сцена. В основном это параметры, которые задаются настройками меша и его материалами. Они влияют на все аспекты и скорость рендеринга.

На момент отработки текущего запроса GPU содержит в себе (получает из памяти) текущие буферы вершин\индексов, текущие вершинные/пиксельные шейдеры и их входные данные. Это приводит нас к тому, что для изменения состояния GPU необходимо создать новый вызов отрисовки. То есть - передать ему ряд новых входных данных и параметров. И это важный момент, потому что чем больше будет вызовов, тем сильнее это загрузит железо. По сути время тратится как на формирование запроса, так и на его обработку. Помимо запросов которые формирует движок, существуют различные программные проверки, обмен и хранение промежуточных результатов. Этим занимаются те самые промежуточные прослойки кода (API) о которых шла речь выше. Как раз они и преобразуют вызовы отрисовки в понятные оборудованию инструкции. Слишком большое количество вызовов может перегрузить CPU во время их формирования, а GPU не будет успевать принять и обработать их.

Обычно в индустрии принято устанавливать пределы допустимого объема вызовов отрисовки на кадр. Тестирование проекта это прямой путь к пониманию того, насколько соблюдено это условие. Тестирование игры на разных стадиях лучше всего помогает разработчикам понять, что они должны предпринять для увеличения производительности. В консольных играх обычно количество вызовов ограничивается интервалом в 2000-3000 тысячи на кадр. Но на деле все индивидуально. Главное здесь то, что существует сам факт ограничений.

Забавный момент - в игре «Ведьмак 3» среднее значение draw call равняется в среднем двум тысячам. Очень неплохо, учитывая уровень графики и обилие деталей. Это говорит о высоком уровне оптимизации финального продукта. И о том, что в случае с Cyberpunk тестирование вряд ли было выполнено в полной мере.

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

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

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

Текстурный атлас

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

На изображении видно, что та же сфера развернута под один атлас и ее половинам присваивается цвет с единой текстурной карты.
На изображении видно, что та же сфера развернута под один атлас и ее половинам присваивается цвет с единой текстурной карты.

Теперь стоит коснуться такого понятия как MIP mapping. Это метод текстурирования, использующий несколько копий одной текстуры с разной детализацией. Чем дальше объект от камеры, тем меньше будет разрешение его текстур. Это еще один аспект оптимизации. Обычно современные движки сами формируют наборы MIP-карт. К атласам это относится таким образом - на дальних расстояниях (более низкое разрешение текстурных карт) соседние элементы в атласах могут размываться и накладываться друг на друга. Об этом стоит помнить.

Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает
Пример текстурного атласа - целое здание можно отрисовать за 2-3 запроса, если сделать все правильно.
Пример текстурного атласа - целое здание можно отрисовать за 2-3 запроса, если сделать все правильно.

Еще стоит отметить такое понятие, как POT (Power Of Two). Размеры текстур должны быть кратны двум. Например 2048х2048. Но при этом текстура 2048х1024 это тоже POT. Так же есть обратный пример - NPOT. Это текстура, у которой присутствует сторона не кратная двум. Например 525х525. Эта текстура будет округлена при обработке в большую сторону, и будет воспринята как 1024х1024. В этой ситуации большой плюс текстурного атласа в том, что на нем можно разместить NPOT текстуры, но при этом сам атлас будет в разрешении кратном двум и отработает правильно, без перегрузок и потерь производительности.

Давайте пройдемся по еще некоторым аспектам оптимизации, направленным на сокращение количества Draw Call.

Батчинг (batching)

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

Разница их в том, что динамический батчинг работает с динамическими (внезапно) объектами. Движок группирует их и обрабатывает одновременно, но при этом такой вид батчинга имеет жесткие ограничения, по этому применяется не всегда. Как пример - динамические листья и мусор, осколки и обломки отлетевшие от чего либо. Вероятнее всего они будут объединены динамическим батчингом.

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

Отсечение по видимости (visibility culling)

Это функция движка, которая позволяет ему провести проверку объектов на видимость в камере. Важно - если у вас есть большая группа, то какие то ее части могут быть вне камеры, но все равно попасть под запрос отрисовки. По этому с группами необходимо работать вдумчиво. Иногда они полезны, иногда вредят производительности. По сути система видимости определив часть группы, воспримет ее как целую группу и не учтет, что она не вся попадает в кадр. Вершинный шейдер отработает для всех вершин этой группы, GPU загрузится лишней работой. Из этого можно сделать следующий вывод - Merging (ручное объединение групп) подходит для мелких объектов, которые находятся близко друг к другу. Например мусор, либо мелкие камни. Просто взгляните на объекты и подумайте, попадут они в кадр полностью, или нет. Как и всегда - все ситуативно и зависит от конкретной ситуации.

Z-Fight

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

Простейший пример Z-fight. Наверняка вы много раз видели такое в играх и теперь знаете, что это и почему так делать не стоит.
Простейший пример Z-fight. Наверняка вы много раз видели такое в играх и теперь знаете, что это и почему так делать не стоит.

Оптимизация 3D модели

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

Редукция полигонов

Упрощение модели при помощи замены группы полигонов на один. В большинстве редакторов это можно делать автоматически и настраивать результат.

Удаление невидимых полигонов

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

Запекание детализации на LOW-poly модель

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

Внимательное распределение групп сглаживания

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

Levels of Detail (LOD)

Это как MIP-map, только для моделей. Набор LOD моделей позволяет снизить детализацию объектов при удалении их от камеры. Это влияет на количество времени и ресурсов для их рендеринга.

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

Итог

Итак, мы разобрались с тем, как работает draw call и зачем он нужен. Мы поняли какой цикл проходит ваша модель на пути от выгрузки в движок, до вывода на экран финальной картинки. Прошлись по основным аспектам оптимизации рендера этой картинки. На эту тему можно говорить долго, но для нас, как для художников, важно следующее:

- Оптимизация контента для игры, это далеко не только яростная обрезка полигонов.
- Важнейший аспект финальной оптимизации, это время, которое уходит на создание одного кадра.
- Понимание того, для каких целей вы делаете модель, это ключ к пониманию того, как ее лучше реализовать.
- Draw Call это наш главный друг и главный враг в работе с моделью. При правильном подходе он поможет вам облегчить себе жизнь, при неправильном - основательно ее подпортит.
- Оптимизация - это громоздкий и сложный процесс, за который отвечает вся команда разработчиков. Но правильное и вдумчивое моделирование, это одна из ее важных частей.
- Планирование работы и своевременное тестирование, это ключ к тому, что на поздних этапах работы вы не утонете в правках и корректировках.
- Прогресс не стоит на месте, железо становится все мощнее, но это не повод пренебрегать всем, что вы узнали в этой статье, так как оптимизации много не бывает.
- А еще, возможно, теперь вы немного поняли, почему cyberpunk 2077 вышел таким, каким вышел:)

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

Draw call. Вызовы отрисовки, оптимизация графики и как это вообще работает
4949
3 комментария

Полезно, но написано суховато

Спасибо, очень понятная и информативная статья <3