Я хотел бы рассказать вам о работе GPU консоли Nintendo DS, об его отличиях от современных GPU, а также выразить своё мнение о том, почему использование Vulkan вместо OpenGL в эмуляторах не принесёт никаких преимуществ.
Я не особо знаю Vulkan, но из прочитанного мне понятно, что Vulkan отличается от OpenGL тем, что работает на более низком уровне, позволяя программистам управлять памятью GPU и подобными вещами. Это может пригодиться для эмуляции более современных консолей, в которых используются проприетарные графические API, обеспечивающие уровни контроля, недоступные в OpenGL.
Например, аппаратный рендерер blargSNES — один из его трюков заключается в том, что во время некоторых операцийй с разными буферами цветов используется один буфер глуби/стенсил-буфер. В OpenGL это невозможно.
Кроме того, между приложением и GPU остаётся меньше мусора, а значит при правильной реализации производительность будет выше. В то время как в драйверах OpenGL полно оптимизаций для стандартных случаев использования и даже для конкретных игр, в Vulkan в первую очередь хорошо написано должно быть само приложение.
То есть, по сути, «с большой силой приходит большая ответственность».
Я не специалист по 3D API, поэтому вернёмся к тому. что я знаю хорошо: GPU консоли DS.
Уже написано несколько статей об отдельных его частях (о его навороченных квадах, о ерунде с viewport, о забавной особенности растеризатора и об удивительной реализации антиалиасинга), но в данной статье мы рассмотрим устройство в целом, но со всеми сочными подробностями. По крайней мере, всё то, что мы знаем.
Сам GPU — это довольно древнее и устаревшее железо. Он ограничен 2048 полигонами и/или 6144 вершинами на кадр. Разрешение равно 256×192. Даже если увеличить это в четыре раза, производительность не будет проблемой. В оптимальных условиях DS может выводить до 122880 полигонов в секунду, что по меркам современных GPU смехотворно.
Теперь перейдём к подробностям работы GPU. На поверхности он выглядит довольно стандартным, но глубоко внутри его работа сильно отличается от работы современных GPU, из-за чего эмуляция некоторых функций может быть усложнена.
GPU разделён на две части: движок геометрии и движок рендеринга. Движок геометрии обрабатывает получаемые вершины, строит полигоны и преобразует их, чтобы можно было передать их движку рендеринга, который (как вы догадались) отрисовывает всё на экране.
Движок геометрии
Довольно стандартный геометрический конвейер.
Стоит упомянуть, что вся арифметика выполняется в целых числах с фиксированной запятой, потому что DS не поддерживает числа с плавающей запятой.
Движок геометрии эмулируется полностью программно (GPU3D.cpp), то есть он не сильно относится к тому, что мы используем для рендеринга графики, но я всё равно расскажу о нём подробно.
1. Преобразования и освещение. Получаемые вершины и координаты текстур преобразуются с помощью наборов из матриц 4×4. Дополнительно к цветам вершин применяется освещение. Здесь всё довольно стандартно, единственная нестандартность заключается в том, как работают координаты текстур (1.0 = один тексел DS). Также стоит упомянуть всю систему стеков матриц, которые в той или иной степени являются аппаратной реализацией glPushMatrix().
2. Настройка полигонлв. Преобразованные вершины собираются в полигоны, которые могут быть треугольниками, четырёхугольниками (quads), полосами из треугольников или полосами из четырёхугольников. Quad-ы обрабатываются нативно и не преобразуются в треугольники, что довольно проблематично, потому что современные GPU поддерживают только треугольники. Однако похоже, что кто-то придумал решение, которое мне нужно протестировать.
3. Отбрасывание. От полигонов можно избавляться в зависимости от направленности на экран и выбранного режима отсечения (culling mode). Тоже довольно стандартная схема. Однако мне нужно разобраться, как это работает для quad-ов.
4. Усечение. Полигоны за пределами объёма видимости устраняются. Полигоны, частично выходящие за эту область, усекаются. Этот шаг не создаёт новых полигонов, а добавляет вершины к существующим. По сути каждая из 6 плоскостей усечения может добавить к полигону одну вершину, то есть в результате у нас может получиться до 10 вершин. В разделе о движке рендеринга я расскажу, как мы с этим справились.
5. Преобразование в область просмотра. Координаты X/Y преобразуются в экранные координаты. Координаты Z преобразуются так, чтобы они помещались в 24-битный интервал буфера глубин.
Интересно то, как обрабатываются координаты W: они «нормализуются», чтобы поместиться в 16-битный интервал. Для этого берётся каждая координата W полигона, и если она больше 0xFFFF, то она сдвигается вправо на 4 позиции, чтобы уместиться в 16 бит. И наоборот, если координата меньше 0x1000, то она сдвигается влево, пока не попадёт в интервал. Предполагаю, что это нужно для получения хороших интервалов, а значит большей точности при интерполяции.
6. Сортировка. Полигоны сортируются так, чтобы просвечивающие полигоны отрисовывались первыми. Потом они сортируются по их координатам Y (ага), что обязательно для непрозрачных и необязательно просвечивающих полигонов.
Кроме того, в этом и есть причина ограничения в 2048 полигонов: для выполнения сортировки их нужно где-то хранить. Существует два внутренних банка памяти, выделенных для хранения полигонов и вершин. Есть даже регистр, сообщающий, сколько полигонов и вершин хранится.
Движок рендеринга
И здесь начинается самое интересное!
После того, как все полигоны были настроены и отсортированы, к работе приступает движок рендеринга.
Первый забавный момент — то, как он выполняет заливку полигонов. Это совершенно непохоже на работу современных GPU, которые выполняют заливку по тайлам и используют оптимизированные для треугольников алгоритмы. Я не знаю, как они все работают, но видел, как это делается в GPU консоли 3DS, и там всё основано на тайлах.
Как бы то ни было, на DS рендеринг выполняется по растровым строкам. Разработчикам пришлось так сделать, чтобы рендеринг мог выполняться параллельно с олдскульными двухмерными тайловыми движками, которые выполняют отрисовку по растровым строкам. Есть небольшой буфер на 48 растровых линий, который можно использовать для корректировки некоторых растровых строк.
Растеризатор — это рендерер выпуклых полигонов на основе растровых строк. Он может обрабатывать произвольное количество вершин. Он может выполнять рендеринг неверно, если передать ему полигоны, которые не являются выпуклыми или имеют пересекающиеся рёбра, например:
Полигон-«бабочка». Всё правильно и великолепно.
Но что если мы его повернём?
Ой.
В чём здесь ошибка? Давайте начертим контур исходного полигона, чтобы разобраться:
Рендерер может заливать только по одному промежутку на растровую строку. Он определяет левое и правое ребро, начинающиеся в самых верхних вершинах, и следует по этим рёбрам, пока не встретит новые вершины.
В показанном выше изображении он начинает с самой верхней вершины, то есть верхней левой, и продолжает выполнять заливку, пока не дойдёт до конца левого ребра (нижняя левая вершина). Он не знает о том, что рёбра пересекаются.
На этом этапе он ищет следующую вершину на левом ребре. Интересно заметить, что он знает о том, что не нужно брать вершины, находящиеся выше текущей, а также знает, что левое и правое ребро поменялись местами. Поэтому он продолжает заливку до конца полигона.
Я бы добавил ещё несколько примеров невыпуклых полигонов, но мы слишком отклонимся от темы.
Давайте лучше разберёмся, как с произвольным количеством вершин работают затенение по Гуро и текстурирование. Существуют барицентрические алгоритмы, используемые для интерполяции данных по треугольнику, но… в нашем случае они не подходят.
Рендерер DS здесь тоже имеет собственную реализацию. Ещё немного любопытных изображений.
Вершины полигона — это точки 1, 2, 3 и 4. Числа не соответствуют настоящему порядку обхода, но смысл вы поняли.
В текущей растровой строке рендерер определяет вершины, непосредственно окружающие рёбра (как сказано выше, он начинает с самых верхних вершин, а затем проходит по рёбрам до их завершения). В нашем случае это вершины 1 и 2 для левого ребра, 3 и 4 для правого ребра.
Наклоны рёбер используются для определения пределов промежутка, то есть точек 5 и 6. В этих точках атрибуты вершин интерполируются на основании вертикальных позиций в рёбрах (или горизонтальных позиций для рёбер, наклоны которых преимущественно по оси X).
Затем для каждого пикселя в промежутке (например, для точки 7) атрибуты на основании позиции по X внутри промежутка интерполируются из атрибутов, ранее вычисленных в точках 5 и 6.
Здесь все использованные коэффициенты равны 50%, чтобы упростить работу, но смысл понятен.
Я не буду вдаваться в подробности интерполяции атрибутов, хоть об этом тоже интересно будет написать. По сути, это правильная с точки зрения перспективы интерполяция, но в ней есть интересные упрощения и особенности.
Теперь поговорим о том, как DS заполняет полигоны.
Какими правилами заполнения он пользуется? Здесь тоже есть много интересного!
Во-первых, существуют разные правила заливки для непрозрачных и просвечивающих полигонов. Но самое важное, что эти правила применяются попиксельно. Просвечивающие полигоны могут иметь непрозрачные пиксели, и они будут следовать тем же правилам, что и непрозрачные полигоны. Можно догадаться, что для эмуляции подобных трюков на современных GPU требуется несколько проходов рендеринга.
Кроме того, на рендеринг разными интересными способами могут влиять разные атрибуты полигонов. В дополнение к довольно стандартным буферам цветов и глубин у рендерера также имеется буфер атрибутов, отслеживающий всевозможные любопытные вещи. А именно: ID полигонов (по отдельности для непрозрачных и просвечивающих полигонов), просвечиваемость пикселя, необходимость применения тумана, направлен ли этот полигон на камеру или от неё (да, и это тоже), и находится ли пиксель на ребре полигона. А может быть и что-то ещё.
Задача эмуляции подобной системы не будет тривиальной. У обычного современного GPU есть стенсил-буфер, ограниченный 8 битами, которого далеко недостаточно для всего, что может хранить буфер атрибутов. Нам нужно придумать хитрый обходной путь.
Давайте разберёмся:
* обновление буфера глубин: обязательно для непрозрачных пикселей, необязательно для просвечивающих.
* ID полигонов: полигонам назначаются 6-битные ID, которые можно использовать в нескольких целях. ID непрозрачных полигонов используются для разметки рёбер. ID просвечивающих полигонов можно использовать для управления тем, где они будут отрисовываться: просвечивающий пиксель не будет отрисовываться, если ID полигона совпадает с уже имеющимся в буфере атрибутов ID просвечивающего полигона. Также оба ID полигона аналогичным образом используются для управления отрисовкой теней. Например, можно создать тень, закрывающую пол, но не персонажа.
(Примечание: тени — это просто реализация стенсил-буфера, здесь нет ничего ужасного.)
Стоит заметить, что при отрисовке просвечивающих пикселей сохраняется уже имеющийся ID непрозрачного полигона, а также флаги рёбер последнего непрозрачного полигона.
* флаг тумана: определяет, нужно ли выполнять для этого пикселя проход применения тумана. Процесс его обновления зависит от того, является ли входящий пиксель непрозрачным или просвечивающим.
* флаг передней грани: вот с ним возникают проблемы. Посмотрите на скриншот:
Sands of Destruction, экраны этой игры — набор трюков. Они не только изменяют свои координаты Y, чтобы повлиять на Y-сортировку. Вероятно, показанный на этом скриншоте экран — самый худший.
В нём используется граничный случай теста глубины: функция сравнения «меньше чем» принимает равные значения, если игра отрисовывает смотрящий в камеру полигон поверх непрозрачных пикселей полигона, направленного от камеры. Да, именно. И значения Z всех полигонов равны нулю. Если не эмулировать эту особенность, на экране будут отсутствовать некоторые элементы.
Думаю, что это было сделано для того, чтобы передняя сторона объекта всегда была видима поверх обратной стороны, даже когда они настолько плоские, что значения Z одинаковы. Всеми этими хаками и трюками рендерер DS похож на аппаратную версию рендереров эпохи DOS.
Как бы то ни было, эмулировать такое поведение через GPU было сложно. А ведь существуют и другие подобные граничные случаи тестирования глубины, которые тоже нужно протестировать и задокументировать.
* флаги рёбер: рендерер отслеживает местонахождение рёбер полигонов. Они используются на последних проходах, а именно при разметке рёбер и антиалиасинге. Существуют также особые правила заполнения непрозрачных полигонов при отключенном антиалиасинге. На показанной ниже схеме проиллюстрированы эти правила:
Примечание: каркасные (wireframe) полигоны рендерятся заполнением только рёбер! Очень умный ход.
Ещё одно забавное примечание о буферизации глубин:
На DS есть два возможных режима буферизации глубин: Z-буферизация и W-буферизация. Кажется, что это довольно стандартно, но только если не вдаваться в детали.
* При Z-буферизации используются координаты Z, преобразованные так, чтобы они помещались в 24-битный интервал буфера глубин. Координаты Z линейно интерполируются по полигонам (с некоторыми странностями, но они не особо важны). Здесь тоже нет ничего нестандартного.
* В W-буферизации координаты W используются «как есть». Современные GPU обычно используют 1/W, но в DS применяется только арифметика с фиксированной запятой, поэтому использовать обратные величины не очень удобно. Как бы то ни было, в этом режиме координаты W интерполируются с коррекцией перспективы.
А вот как выглядят финальные проходы рендеринга:
* разметка рёбер: пикселям, у которых заданы флаги рёбер, присваивается цвет, взятый из таблицы и определённый на основании ID непрозрачного полигона.
Они будут цветными рёбрами полигонов. Стоит заметить, что если поверх непрозрачного полигона отрисовывается просвечивающий полигон, то рёбра полигона всё равно будут цветными.
Побочный эффект принципа усечения: границы, в которых полигоны пересекаются с границами экрана, тоже будут цветными. Можно например заметить это на скриншотах Picross 3D.
* туман: он применяется к каждому пикселю на основании значений глубин, использованных для индексации таблицы плотности тумана. Как можно догадаться, он применяется к тем пикселям, которым заданы флаги тумана в буфере атрибутов.
* антиалиасинг (сглаживание): он применяется к рёбрам (непрозрачных) полигонов. На основании наклонов рёбер при рендеринге полигонов вычисляются значения покрытия пикселей. На последнем проходе эти пиксели смешиваются с пикселями под ними с помощью хитрого механизма, который я описал в предыдущем посте.
Антиалиасинг не должен (и не может) эмулироваться таким образом на GPU, поэтому здесь это не важно.
За исключением того, что если разметка рёбер и антиалиасинг должны применяться к одним и тем же пикселям, они получают только размертку рёбер, но с 50-процентной непрозрачностью.
Кажется, я более-менее хорошо описал процесс рендеринга. Мы не углублялись в смешение текстур (комбинирование цветов вершин и текстур), но его можно эмулировать во фрагментном шейдере. То же самое относится к разметке рёбер и туману, при условии, что мы найдём способ обойти всю эту систему с буфером атрибутов.
Но в целом я хотел донести следующее: OpenGL или Vulkan (а также Direct3D, или Glide, или что угодно ещё) здесь не помогут. У наших современных GPU более чем достаточно мощности для работы с сырыми полигонами. Проблема заключается в подробностях и особенностях растеризации. И дело даже не в идеальности пикселей, достаточно для примера посмотреть на issue tracker эмулятора DeSmuME, чтобы понять, с какими проблемами встречаются разработчики при рендеринге через OpenGL. С этими же проблемами нам тоже придётся как-то справляться.
Также замечу, что использование OpenGL позволит портировать эмулятор, допустим на Switch (потому что пользователь Github по имени Hydr8gon начал создавать порт нашего эмулятора на Switch).
Так что… пожелайте мне удачи.
Источник