Доброго времени суток,
Неожиданной для себя, обнаружил, что по какой-то причине, присутствует не так много содержательных статей о работе технологий Unreal Engine 5 на русском языке. Я решил исправить эту несправедливость. И чтобы не повторятся с англоязычными статьями или небольшим количеством статей на русском, я буду рассматривать, почему именно Nanite даёт преимущество. С большим уходом вглубь работы видеокарты и оптимизаций.
В первой части, я рассматриваю работу алгоритма отсечения окружения, в частности, реализацию Теста Глубины.
Стоит уточнить, что технология Nanite рисует честное изображения не прибегая к методам подделки изображения с потерей качества. Разработчикам гарантируются работа в полных 32-битах на компоненту и 32-битный буфер глубины - полноценный рендер.
Хватит придумывать карты
Ключом к оптимизации, является понимание нюансов архитектуры современных видеокарт. Нюансов, которыми пренебрегают изобретатели карт.
Картографов и картёжников развелось столько, что страшно по улицам ходить. Никогда не знаешь, может этот прохожий, пристально смотрящий на вас, бросил взгляд и не отводит, потому что понял, что смотрит долго и не знает как отвести, бросил взгляд не случайно? может он только и ждёт, чтобы припереть вас к стенке и рассказать о своей новой карте царапин от осколков направленного взрыва при приседании с поворотом камеры на юг, которая применяется во время анимации на одном юните за картой для правильного отблеска по краям экрана. Симуляция, скажите вы? 4 пикселя - скажу я. Карта для каждой координаты? для каждого угла поворота? для каждого отношения координаты к углу ворота? карта карт? Эти принтеры - неостановимы.
По моим подсчётам, при населении планеты в пределах 8 млрд. и 2 недостающих стаканчиков возле кулера, в среднем по палате, каждую минуту, где-то изобретается одна новая карта, и это они ещё половину в голове отсеивают. В целом, решение проблемы просто и понятно, выдавать патроны бесплатно оптимизация!
Современные видеочипы имеют просто колоссальный перевес вычислительной мощности по отношению к пропускной способности памяти.
Возьмём в пример средненькую RTX 2070 (чип TU106)
Пропускная способность памяти (bandwidth): 448 GB/s
Шина памяти: 256 bit
Частота памяти: 1750 MHz (DDR x2, Quad Pumped x4)
Частота чипа: 1410 MHz (частота ядер x2)
Количество ядер: 2304
Количество ядер блока: 32
Посчитаем скорость при упоре в память.
За один такт каждое ядро блока может писать или читать из L1 кэша по 32 бита на ядро:
32(операнд) * 32(ядер) = 1024 бита = 128 байт
Память:
256(шина) * 8 (раз за такт) = 2048 бит = 256 байт
Всего 2 малых блоков достаточно, чтобы полностью нагружать память при простом копировании. Теперь возьмём базовую операцию C = A + B, что равно одной инструкции, за эту инструкцию, производится:
Чтение 2x32 бита
Запись 1x32 бит
32 (операнд) * 2(чтение А, B) + 32(запись, C) * 32(ядра) = 3072 бит = 384 байт за такт
Рендер в очередную карту - самое ужасное преступление против производительности видеокарты. В современных играх, этих карт используются по 16 штук. Вначале, их пишет первый проход рендера, а потом читает шейдер материалов, по 16 штук каждый раз. Это именно та причина, почему пиксельный шейдер работает большую часть времени, вплоть до 90%.
Тест глубины
Проблема определения видимости объектов - плохо ложится на алгоритмы, и даже самые сложные из алгоритмов определения видимости, в качестве последнего этапа используют - тест глубины. Тест глубины правильно определяет перекрытие пикселя
Стандартный способ, который предлагает API, это подход с использованием Occlusion Query(Запрос Окружения), это запрос количества пикселей, которые прошли растеризацию, включая тест глубины, тест трафарета и альфа-тест при смешивании цветов. Почти полный рендер, без конечного этапа - запись в буфер кадра. Что позволяет определять даже прозрачные объекты, которые влияют на изображение. Если запрос возвращает количество пикселей отличное от минимального, обычно это число 4 или 0, то вызывается уже полная отрисовка объекта, с записью цвета пикселя в кадр. В многопроходных рендерах, тест глубины производится первым проходом. Прошедшие его, ставятся в очередь на последующие проходы.
Слабым место подхода с использованием запроса окружения, являются, перерисовки. Точнее, он никак не решает проблему перерисовок. Порядок теста влияет на результат, сначала объект может быть отрисован, а потом поверх него может быть отрисован другой объект, который полностью перекрывает первый. Но первый успел пройти проверку и будет полностью отрисован, а потом поверх него будет полностью отрисован второй объект.
Карта видимости
Карта видимости или буфер видимости, это попытка решить проблему перерисовок. Пиксельные шейдеры - и так используют огромное количество карт, а материалы достаточно сложны, что в итоге делает перерисовку - дорогой и, главное, бесполезной тратой ресурсов. Перерисовки, могут достигать несколько десятков раз на пиксель, перерисовывая полный кадр по нескольку раз.
Карта видимости - это новый подход, во многом экспериментальный, в основном он применяется в исследовательских работах. Среди крупных игровых движков, данный метод не применялся, или об этом широко не известно.
Простой шейдерный проход, состоит из двух шейдеров, вершинный и пиксельный. Стандартный алгоритм следующий:
Выбор вершины
Трансформация: установка позиции, поворота и проекция на двумерное пространство
Конечные 2D вершины имеют глубину и позицию в экранном пространстве
Организация групп вершин в полигоны
Определение нормали полигона
Отбрасывание полигона, если его нормаль смотрит в обратную от экрана сторону
Определение положения пикселя
Интерполяция атрибутов для координат пикселя в экранном пространстве
Тест глубины и трафарета пикселя
Запуск пиксельного шейдера
Буфер видимости работает по принципу малой стоимости перерисовки. Классический подход для буфера видимости подразумевает рендер в 4 карты (компоненты) - буфер глубины, карту развёртки UV и ID материала. По ID материала определяется шейдер, UV определяет положение текстуры и UV уникальна для позиции пикселя, а глубина служит для обычного теста. По UV можно также интерполировать все остальные атрибуты для текущего пикселя. Главное, что 4 значения на пиксель - это сильно дешевле, чем полный рендер. Такое значение тоже перерисовывается, но цена записи очень низкая.
Итоговый пиксель по 32 бита на компоненту:
Z - Глубина
ID - материала
U - координата развёртки
V - координата развёртки
32(размер) * 4(компонент) = 128 бит = 16 байт на пиксель
Nanite - запредельная мощность
Nanite подходит к решению задачи с ещё большим рвением. Нанит обозначает проблемой и координаты развёртки, и идентификатор материала. Казалось бы, куда уж лучше. Но Нанит смело врывается и раздаёт терафлопсы направо и налево. Ведь для Нанита нагрузка совсем другая, это не пара миллионов, это несколько миллиардов полигонов для обработки. Любая проблема стандартного рендера, буквально, умножается в тысячу раз. Да, большая часть полигонов отбрасывается ещё на этапе отсечения геометрии, но Нанит решил - он должен иметь экстремальную производительность.
Проблема всех Буферов Видимости в чтении атрибутов вершины, которые потом проходят интерполяцию умноженную на количество пикселей полигона - это одновременно и операции с памятью и вычисления.
Нанит использует свои 3 составляющих для Буфера Видимости:
32 бит - (Z) глубина
25 бит - № Экземпляра
7 бит - № Полигона
Итого, 64 бит = 8 байт на пиксель, с полной точностью глубины. Рассмотрим всю эпическую гениальность такого решения.
Стандартный шейдер может сам определять, уникальные для вершины - номер вершины и номер вызова отрисовки(экземпляра в определениях), они вычисляются просто по счётчику самого видеочипа и доступны через встроенную функцию. Это фактический бесплатно как и индекс при проходе по массиву. В таком случае, чтение атрибутов вершины просто не требуется на первом этапе, а стоимость чтения на полигон ограничивается только, минимально необходимыми - координатами, самой вершины. Нагрузка на память, умножаемая на количество пикселей и полигонов - просто, отсутствует. Очень дешёвое чтение, вкупе с очень дешёвой стоимостью перерисовки.
Решение Нанит, не очевидно, пока его не разберёшь. В отличии от обычных Буферов Видимости, Нанит необходим второй проход для определения материалов. Материал лежит по адресу полигона, который Нанит уже вычислил, но остальные атрибуты вершины придётся снова вычислять, то есть нужно заново вызвать полную отрисовку полигонов, благо, у нас есть точный список всех видимых полигонов, а трансформация - одна для целого экземпляра и не нагружает память. Зато получается значительный бонус, из-за прямого доступа к данным вершины, разработчики больше не ограничены текстурированием и использованием UV. Разработчик может организовывать и читать любые кастомные атрибуты вершины.
Обычные Буферы Видимости использует:
Вершина:
32 * 3 бит - позиция вершины
32 * 2 бит - UV координаты развёртки
32 бит - идентификатор материала
192(вершина) * 3(количество вершин) = 576 бит = 72 байта на полигон
Перерисовка: 128 бит = 16 байт на пиксель
Нанит
Вершина:
32 * 3 бит - позиция вершины
96 (вершина) * 3(количество вершин) = 288 бит = 36 байт на полигон
Перерисовка: 64 бит = 8 байт на пиксель
Повторный обход вершин: 288 бит = 36 байт на полигон
После этих подсчётов, становится понятно, что множители и для пикселя и для полигона значительно выше для обычного буфера видимости. В то время как, Нанит требует больше базовой работы, но значительно легче при большой нагрузке. Так же стандартный буфер проводит больше вычислений - интерполяций UV.
Нанит не проводит интерполяции самих UV для каждого пикселя, при этом пиксельный шейдер получает полный набор всех исходных атрибутов так как если бы ни какого Буфера Видимости не было. Иначе, необходимо адаптировать все шейдеры под UV и ID материала - этого достаточно для большинства шейдеров, но не для всех, при этом теряется возможность оптимизации. Нанит - обходит эту проблему. В итоге, Нанит всё ещё быстрее и менее прожорлив, чем другие подходы.
Выводы
Буфер Видимости - это отличный шаг для оптимизации, но Нанит смог выжать максимум из, без того, быстрого метода.
Unreal Engine использует множество техник оптимизации, в этот раз я рассмотрел лишь одну из них. Итоговая мощность Nanite - поражает, даже зрителей, привыкших к качеству экспериментальных демонстраций.