Хабрахабр

[Перевод] GPU консоли Nintendo DS и его интересные особенности

Я хотел бы рассказать вам о работе GPU консоли Nintendo DS, об его отличиях от современных GPU, а также выразить своё мнение о том, почему использование Vulkan вместо OpenGL в эмуляторах не принесёт никаких преимуществ.

Это может пригодиться для эмуляции более современных консолей, в которых используются проприетарные графические API, обеспечивающие уровни контроля, недоступные в OpenGL.
Например, аппаратный рендерер blargSNES — один из его трюков заключается в том, что во время некоторых операцийй с разными буферами цветов используется один буфер глуби/стенсил-буфер. Я не особо знаю Vulkan, но из прочитанного мне понятно, что Vulkan отличается от OpenGL тем, что работает на более низком уровне, позволяя программистам управлять памятью GPU и подобными вещами. В OpenGL это невозможно.

В то время как в драйверах OpenGL полно оптимизаций для стандартных случаев использования и даже для конкретных игр, в Vulkan в первую очередь хорошо написано должно быть само приложение. Кроме того, между приложением и GPU остаётся меньше мусора, а значит при правильной реализации производительность будет выше.

То есть, по сути, «с большой силой приходит большая ответственность».

что я знаю хорошо: GPU консоли DS. Я не специалист по 3D API, поэтому вернёмся к тому.

По крайней мере, всё то, что мы знаем. Уже написано несколько статей об отдельных его частях (о его навороченных квадах, о ерунде с viewport, о забавной особенности растеризатора и об удивительной реализации антиалиасинга), но в данной статье мы рассмотрим устройство в целом, но со всеми сочными подробностями.

Он ограничен 2048 полигонами и/или 6144 вершинами на кадр. Сам GPU — это довольно древнее и устаревшее железо. Даже если увеличить это в четыре раза, производительность не будет проблемой. Разрешение равно 256x192. В оптимальных условиях DS может выводить до 122880 полигонов в секунду, что по меркам современных GPU смехотворно.

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

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

Движок геометрии

Довольно стандартный геометрический конвейер.

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

Движок геометрии эмулируется полностью программно (GPU3D.cpp), то есть он не сильно относится к тому, что мы используем для рендеринга графики, но я всё равно расскажу о нём подробно.

Преобразования и освещение. 1. Дополнительно к цветам вершин применяется освещение. Получаемые вершины и координаты текстур преобразуются с помощью наборов из матриц 4x4. 0 = один тексел DS). Здесь всё довольно стандартно, единственная нестандартность заключается в том, как работают координаты текстур (1. Также стоит упомянуть всю систему стеков матриц, которые в той или иной степени являются аппаратной реализацией glPushMatrix().

Настройка полигонлв. 2. Quad-ы обрабатываются нативно и не преобразуются в треугольники, что довольно проблематично, потому что современные GPU поддерживают только треугольники. Преобразованные вершины собираются в полигоны, которые могут быть треугольниками, четырёхугольниками (quads), полосами из треугольников или полосами из четырёхугольников. Однако похоже, что кто-то придумал решение, которое мне нужно протестировать.

Отбрасывание. 3. Тоже довольно стандартная схема. От полигонов можно избавляться в зависимости от направленности на экран и выбранного режима отсечения (culling mode). Однако мне нужно разобраться, как это работает для quad-ов.

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

Преобразование в область просмотра. 5. Координаты Z преобразуются так, чтобы они помещались в 24-битный интервал буфера глубин. Координаты X/Y преобразуются в экранные координаты.

Для этого берётся каждая координата W полигона, и если она больше 0xFFFF, то она сдвигается вправо на 4 позиции, чтобы уместиться в 16 бит. Интересно то, как обрабатываются координаты W: они «нормализуются», чтобы поместиться в 16-битный интервал. Предполагаю, что это нужно для получения хороших интервалов, а значит большей точности при интерполяции. И наоборот, если координата меньше 0x1000, то она сдвигается влево, пока не попадёт в интервал.

Сортировка. 6. Потом они сортируются по их координатам Y (ага), что обязательно для непрозрачных и необязательно просвечивающих полигонов. Полигоны сортируются так, чтобы просвечивающие полигоны отрисовывались первыми.

Существует два внутренних банка памяти, выделенных для хранения полигонов и вершин. Кроме того, в этом и есть причина ограничения в 2048 полигонов: для выполнения сортировки их нужно где-то хранить. Есть даже регистр, сообщающий, сколько полигонов и вершин хранится.

Движок рендеринга

И здесь начинается самое интересное!

После того, как все полигоны были настроены и отсортированы, к работе приступает движок рендеринга.

Это совершенно непохоже на работу современных GPU, которые выполняют заливку по тайлам и используют оптимизированные для треугольников алгоритмы. Первый забавный момент — то, как он выполняет заливку полигонов. Я не знаю, как они все работают, но видел, как это делается в GPU консоли 3DS, и там всё основано на тайлах.

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

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

Полигон-«бабочка». Всё правильно и великолепно.

Но что если мы его повернём?

Ой.

Давайте начертим контур исходного полигона, чтобы разобраться: В чём здесь ошибка?

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

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

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

Я бы добавил ещё несколько примеров невыпуклых полигонов, но мы слишком отклонимся от темы.

Существуют барицентрические алгоритмы, используемые для интерполяции данных по треугольнику, но… в нашем случае они не подходят. Давайте лучше разберёмся, как с произвольным количеством вершин работают затенение по Гуро и текстурирование.

Ещё немного любопытных изображений. Рендерер DS здесь тоже имеет собственную реализацию.

Вершины полигона — это точки 1, 2, 3 и 4. Числа не соответствуют настоящему порядку обхода, но смысл вы поняли.

В нашем случае это вершины 1 и 2 для левого ребра, 3 и 4 для правого ребра. В текущей растровой строке рендерер определяет вершины, непосредственно окружающие рёбра (как сказано выше, он начинает с самых верхних вершин, а затем проходит по рёбрам до их завершения).

В этих точках атрибуты вершин интерполируются на основании вертикальных позиций в рёбрах (или горизонтальных позиций для рёбер, наклоны которых преимущественно по оси X). Наклоны рёбер используются для определения пределов промежутка, то есть точек 5 и 6.

Затем для каждого пикселя в промежутке (например, для точки 7) атрибуты на основании позиции по X внутри промежутка интерполируются из атрибутов, ранее вычисленных в точках 5 и 6.

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

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

Теперь поговорим о том, как DS заполняет полигоны.

Здесь тоже есть много интересного! Какими правилами заполнения он пользуется?

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

В дополнение к довольно стандартным буферам цветов и глубин у рендерера также имеется буфер атрибутов, отслеживающий всевозможные любопытные вещи. Кроме того, на рендеринг разными интересными способами могут влиять разные атрибуты полигонов. А может быть и что-то ещё. А именно: ID полигонов (по отдельности для непрозрачных и просвечивающих полигонов), просвечиваемость пикселя, необходимость применения тумана, направлен ли этот полигон на камеру или от неё (да, и это тоже), и находится ли пиксель на ребре полигона.

У обычного современного GPU есть стенсил-буфер, ограниченный 8 битами, которого далеко недостаточно для всего, что может хранить буфер атрибутов. Задача эмуляции подобной системы не будет тривиальной. Нам нужно придумать хитрый обходной путь.

Давайте разберёмся:

* обновление буфера глубин: обязательно для непрозрачных пикселей, необязательно для просвечивающих.

ID непрозрачных полигонов используются для разметки рёбер. * ID полигонов: полигонам назначаются 6-битные ID, которые можно использовать в нескольких целях. Также оба ID полигона аналогичным образом используются для управления отрисовкой теней. ID просвечивающих полигонов можно использовать для управления тем, где они будут отрисовываться: просвечивающий пиксель не будет отрисовываться, если ID полигона совпадает с уже имеющимся в буфере атрибутов ID просвечивающего полигона. Например, можно создать тень, закрывающую пол, но не персонажа.

(Примечание: тени — это просто реализация стенсил-буфера, здесь нет ничего ужасного.)

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

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

Посмотрите на скриншот: * флаг передней грани: вот с ним возникают проблемы.

Sands of Destruction, экраны этой игры — набор трюков. Они не только изменяют свои координаты Y, чтобы повлиять на Y-сортировку. Вероятно, показанный на этом скриншоте экран — самый худший.

Да, именно. В нём используется граничный случай теста глубины: функция сравнения «меньше чем» принимает равные значения, если игра отрисовывает смотрящий в камеру полигон поверх непрозрачных пикселей полигона, направленного от камеры. Если не эмулировать эту особенность, на экране будут отсутствовать некоторые элементы. И значения Z всех полигонов равны нулю.

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

А ведь существуют и другие подобные граничные случаи тестирования глубины, которые тоже нужно протестировать и задокументировать. Как бы то ни было, эмулировать такое поведение через GPU было сложно.

Они используются на последних проходах, а именно при разметке рёбер и антиалиасинге. * флаги рёбер: рендерер отслеживает местонахождение рёбер полигонов. На показанной ниже схеме проиллюстрированы эти правила: Существуют также особые правила заполнения непрозрачных полигонов при отключенном антиалиасинге.

Примечание: каркасные (wireframe) полигоны рендерятся заполнением только рёбер! Очень умный ход.

Ещё одно забавное примечание о буферизации глубин:

Кажется, что это довольно стандартно, но только если не вдаваться в детали. На DS есть два возможных режима буферизации глубин: Z-буферизация и W-буферизация.

Координаты Z линейно интерполируются по полигонам (с некоторыми странностями, но они не особо важны). * При Z-буферизации используются координаты Z, преобразованные так, чтобы они помещались в 24-битный интервал буфера глубин. Здесь тоже нет ничего нестандартного.

Современные GPU обычно используют 1/W, но в DS применяется только арифметика с фиксированной запятой, поэтому использовать обратные величины не очень удобно. * В W-буферизации координаты W используются «как есть». Как бы то ни было, в этом режиме координаты W интерполируются с коррекцией перспективы.

А вот как выглядят финальные проходы рендеринга:

* разметка рёбер: пикселям, у которых заданы флаги рёбер, присваивается цвет, взятый из таблицы и определённый на основании ID непрозрачного полигона.

Стоит заметить, что если поверх непрозрачного полигона отрисовывается просвечивающий полигон, то рёбра полигона всё равно будут цветными. Они будут цветными рёбрами полигонов.

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

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

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

Антиалиасинг не должен (и не может) эмулироваться таким образом на GPU, поэтому здесь это не важно.

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

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

У наших современных GPU более чем достаточно мощности для работы с сырыми полигонами. Но в целом я хотел донести следующее: OpenGL или Vulkan (а также Direct3D, или Glide, или что угодно ещё) здесь не помогут. И дело даже не в идеальности пикселей, достаточно для примера посмотреть на issue tracker эмулятора DeSmuME, чтобы понять, с какими проблемами встречаются разработчики при рендеринге через OpenGL. Проблема заключается в подробностях и особенностях растеризации. С этими же проблемами нам тоже придётся как-то справляться.

Также замечу, что использование OpenGL позволит портировать эмулятор, допустим на Switch (потому что пользователь Github по имени Hydr8gon начал создавать порт нашего эмулятора на Switch).

Так что… пожелайте мне удачи.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»