[Перевод] Пишем 3D-рендерер в стиле первой PlayStation

Я занялся новым хобби-проектом, который мне очень нравится. Я создаю вымышленную консоль, источником вдохновения для которой стали технологии эпохи PS1. Проект довольно масштабный, но сегодня я хочу поговорить о рендеринге, который стал моим первым шагом к его реализации. В этой статье я подробно расскажу о том, что узнал в процессе исследования PS1 и других ретроконсолей. И, разумеется, о том, как я реализовал рендеринг в вымышленной консоли, которую назвал Polybox. Я не буду объяснять рендеринг 2D-спрайтов, потому что это довольно просто, а статья и так вышла достаточно длинной.

Вот конечный результат, который я получил в консоли Polybox:

image-loader.svg


Polybox!

Что такое вымышленная консоль?


На случай, если вы незнакомы с вымышленными консолями (fantasy console), скажу, что это придуманные виртуальные игровые консоли для создания и запуска игр в ретростиле. Самым известным примером является Pico-8 — 8-битная 2D-консоль, разрабатывать игры для которой — настоящее удовольствие. Существует уже достаточно много вымышленных 2D-консолей, но с 3D всё гораздо сложнее. Тем не менее, я решил разобраться, заработает ли моя вымышленная 3D-консоль, и за основу взял свою любимую старую 3D-консоль — Playstation 1. Выбор PS1 был обусловлен и ещё одной важной причиной — возможности рендеринга этой консоли достаточно просты, что поможет мне не усложнять разработку.
Разумеется, первым делом мне нужно было разобраться, что же придавало играм для PS1 столь уникальный внешний вид. Я не специалист по ретрожелезу, и недостаточно опытен, чтобы объяснять в этой статье, как работало оборудование PS1, но я провёл достаточно исследований в этой области и оставил в конце статьи ссылки, которые могут показаться вам интересными. Изучение этих материалов дало мне чёткое понимание основных составляющих этого внешнего вида. Разумеется, мне также пришлось самому много играть и разглядывать игры для PS1.

d4398d4f093ea345f810b58bd2288d6a.jpg


Syphon Filter, изображение из @PS1Aesthetics

a30eadfd600ff5ece8771f2802e395fd.jpg


Metal Gear Solid, изображение из @PS1Aesthetics

Основные составляющие приблизительно такие:

  • Низкополигональные модели и текстуры низкого разрешения
  • Дёрганые полигоны
  • Отсутствие буфера глубин
  • Искажённое текстурирование
  • Скачущее и колеблющееся наложение текстур
  • Вершинное освещение
  • Создающий ощущение глубины туман


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


Давайте подробнее рассмотрим каждый из этих пунктов и расскажем об их влиянии на внешний вид.

Низкополигональные модели и текстуры низкого разрешения


Наиболее очевидный и понятный аспект внешнего вида игр PS1 — это общее низкое разрешение всего. Он возник из-за того, что geometry transformation engine (GTE) попросту не мог обрабатывать большое количество полигонов за кадр. В этой статье Википедии говорится, что GTE мог обрабатывать всего 90000 полигонов в секунду с наложением текстур, освещением и плавным затенением. Подозреваю, что на практике это ограничение было не постоянным числом, а сильно зависело от игры и от тех функций GTE, которые вы использовали. Но самое главное то, что этого очень мало, по сравнению, допустим, с современными GPU, которые способны в буквальном смысле рендерить миллионы полигонов за кадр, не говоря уже о секунде.

image-loader.svg


Модель Риноа Хартилли (Final Fantasy 8)

image-loader.svg


Crash Bandicoot

Я не буду рассказывать об анимации, потому что в основном она зависела от разработчика, но упомяну, что во многих играх для оптимизации производительности использовался фиксированный вершинный скиннинг (vertex skinning) вместо смешивания весов (weight blending), и это сильно повлияло на внешний вид анимации персонажей во многих играх для PS1.


Ещё одно очевидное ограничение — это текстурирование. Текстурные страницы на оборудовании PS1 имели ограничение 256×256 пикселей, то есть, похоже, это был максимальный размер текстур, который использовался в большинстве моделей. В наши дни в играх часто используется несколько 4k-текстур (4096×4096) на каждую модель, особенно для предметов главного героя или основных персонажей, то есть это достаточно серьёзное ограничение.

Текстурирование — важный аспект создания стилей графики на PS1. Отсутствие сложного освещения и затенения приводило к тому, что художники обычно отрисовывали освещение на текстурах и активно использовали текстуры для детализации моделей, которые были относительно низкополигональными.


image-loader.svg


Риноа Хартилли (Final Fantasy 8) и её крошечная текстура размером 128×128

Ютубер LittleNorwegians записал потрясающий туториал, показывающий, как создавались текстуры на PS1 в играх наподобие Metal Gear Solid и Silent Hill. Считается, что модели на PS1 имели текстуры, созданные из состыкованных и отредактированных реальных фотографий, но хотя реальные фотографии и использовались в текстурах на PS1, во всех проведённых мной исследованиях текстуры были сочетаниями фотографий, рисования вручную и различных эффектов, как показано в видео:


Небольшое примечание о графике окружений


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

@98Demake выполнил превосходный анализ графики окружений Silent Hill. Вы видите, как эта техника активно здесь используется:

fd1bf2cd9e49dd8c04ea5f6f445cd142.jpg


Часть графики окружений из Silent Hill. Как видите, текстура — это сетка из многократно используемых кусков.

2c85a9df2d5b0e62355cfb54ae9c4693.jpg


Та же часть графики окружений, но в каркасном отображении, чтобы можно было увидеть, как она создана.

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

image-loader.svg


Демонстрация того, как был изготовлен танковый ангар

Однако давайте вернёмся к техническим подробностям системы рендеринга…

Дёрганые полигоны


Думаю, почти все помнят, что на PS1 всё постоянно дёргалось и тряслось. Поначалу я нашёл информацию о том, что все винят в этом geometry transformation engine (GTE), имевший только вычисления с фиксированной запятой, из-за чего всё было неточным. Но я выяснил, что основная причина не в этом. Не поймите меня превратно — да, иногда он влияет на неточность вычислений, но картинка в играх тряслась не из-за этого. Похоже, что настоящей причиной было отсутствие субпиксельной растеризации.

Субпиксельная растеризация — это когда алгоритм растеризации учитывает вершины и рёбра, неидеально совпадающие с сеткой пикселей. Из-за отсутствия субпиксельности Playstation привязывала все вершины к сетке пикселей. Чтобы продемонстрировать это на практике, я создал эту анимацию. На ней показаны два вращающихся с одной угловой скоростью треугольника. Второй — это то, что бы вы увидели на PS1.

Современность, субпиксельная растеризация:


Но на PS1 это выглядит так:
Если ещё учесть, что разрешение составляло 320×240, причины дёрганья полигонов вполне очевидны. Присмотревшись, можно понять, что сама форма полигона меняется из-за того, что вершины движутся в соответствии с сеткой, поэтому весь треугольник дрожит и колеблется.

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

vec2 grid = targetResolution.xy * 0.5;
vec4 snapped = vertInClipSpace;
snapped.xyz = vertInClipSpace.xyz / vertInClipSpace.w;
snapped.xy = floor(grid * snapped.xy) / grid;  // This is actual grid
snapped.xyz *= vert.w;


Отсутствие буфера глубин


Ещё одним наиболее значимым ограничением архитектуры рендеринга PS1 является отсутствие буфера глубин, имеющее серьёзные последствия, о которых мы и поговорим. Во-первых, если вы не знаете, что такое буфер глубин, то сообщу, что это отображение кадра в оттенках серого, в котором хранится «значение» глубины для каждого пикселя, сообщающее, насколько далеко он находится от камеры.

image-loader.svg


Простой буфер глубин из моего рендерера

PS1 не имела такого буфера глубин и разработчики сами должны были отрисовывать все необходимые примитивы в правильном порядке, начиная сзади и постепенно приближаясь к камере. В библиотеках разработки для PS1 для помощи в этом процессе существовали так называемые таблицы упорядочивания (ordering table). Примитивы помещались в таблицу в месте, привязанном к их расстоянию до камеры, после чего программа обходила список для рендеринга сцены. Однако иногда возникал конфликт между несколькими элементами относительно их глубины, что приводило к мерцающим багам.

Пример подобной ошибки глубин можно увидеть в левом верхнем углу кадра в этом клипе из Tomb Raider (смотреть с 53:35)


Я решил не воссоздавать подобный глитч в своём рендерере PS1, и на то была пара причин:

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


Я считаю, что вполне возможно воссоздать подобные глитчи на современном GPU, или отказавшись от буфера глубин и реализовав ordering tables, или переписывая значения в буфере глубин постоянными глубинами для каждого примитива: таким образом, мы заставим растеризатор выполнять сортировку для каждого примитива. На момент написания я не пробовал этого, но дополню статью, если мне это удастся.

Как говорилось выше, существуют и другие последствия отсутствия буфера глубин, сильно влиявшие на создание уникального внешнего вида игр на PS1, и одно из них — это искажённое текстурирование.

Искажённое текстутирование


Гугля информацию о рендеринге на PS1, вы неизбежно наткнётесь на упоминание искажения текстур — вероятно, одного из самых хорошо известных аспектов внешнего вида игр на PS1. При больших углах относительно камеры текстуры часто выглядели сильно искажёнными. Я покажу это на примере:
Чтобы понять, почему такое происходит, давайте сначала рассмотрим наивный способ наложения 2D-текстуры на 3D-поверхность. Как вы должно быть знаете, это обычно выполняется присвоением каждой вершине 2D-координаты. При растеризации пикселя на экран мы линейно интерполируем 2D-координаты по треугольнику, а затем смотрим на цвет текстуры в этой интерполированной координате и помещаем её на экран.

Простая линейная интерполяция задаётся следующим образом:

$u_{\alpha} = (1-\alpha)u_{0}+\alpha u_{1}$


Здесь $\alpha$ — это некое значение в интервале от 0 до 1 на поверхности треугольника в экранном пространстве. То есть существует два значения альфы (второе мы используем для координаты $v$), позволяющие задать точку на поверхности треугольника. Так как они плавно смешиваются по площади треугольника, можно использовать их в приведённом выше уравнении для смешения UV-координат каждой из вершин. В результате получится UV-координата, которую можно использовать для поиска в текстуре.

Это называется «аффинным наложением текстур» (affine texture mapping). Именно такая методика используется на PS1, и её работа показана в клипе выше. Почему же она не работает? Причина в перспективе. Давайте возьмём четырёхугольник из видео, и взглянем на него сверху и под углом.

image-loader.svg


Простой повёрнутый четырёхугольник

Помните, что мы делаем всё в экранном пространстве. То есть чтобы отобразить центр повёрнутого четырёхугольника, мы берём центр диагонали в экранном пространстве и накладываем его на центр четырёхугольника. Однако вы увидите, что брать центр диагонали неправильно.

image-loader.svg


Аффинное наложение текстур

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

Как это исправить? В современных GPU используется следующее свойство: можно выполнять линейные интерполяции, преобразовав 2D-координаты в нечто линейное в экранном пространстве. Это можно сделать, разделив их на значение глубины координаты.

Это даёт нам следующий новый набор координат:

$\frac{u_{n}}{z_{n}}, \frac{v_{n}}{z_{n}}, \frac{1}{z_{n}}$


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

$u_{\alpha} = \frac{(1-\alpha)\frac{u_{0}} {z_{0}}+\alpha \frac{u_{1}} {z_{1}}} {(1-\alpha)\frac{1}{z_{0}}+\alpha \frac{1}{z_{1}}}$


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

image-loader.svg


Наложение текстур с перспективной коррекцией. Синими засечками показано старое наложение с линейной интерполяцией

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

Однако при работе с PS1 такая интерполяция гораздо более затратна, в частности, в ней используется деление; кроме того, geometry transformation engine поместил все вершины в экранное пространство, поэтому у нас даже нет значения глубины, и мы при всём желании не могли бы реализовать такой алгоритм. Поэтому на PS1 приходилось выполнять перспективно некорректное наложение текстур, и именно это проявляется как искажённые текстуры.

Однако реализовать это на современных GPU чрезвычайно просто, потому что мы можем явным образом указать, что данные вершин не нужно интерполировать с коррекцией перспективы. И в hlsl, и в glsl можно указать для входящих данных вершин квалификатор типа noperspective, и вуаля, всё готово.

Скачущее и колеблющееся наложение текстур


Существует и ещё одна разновидность странного поведения текстур, которую вы могли заметить при прохождении игр на PS1 — ситуации, когда вся текстура сама «скачет» между кажущимися разными наложениями. Это довольно сложно описать, поэтому я просто покажу клип, где я приближаюсь и отдаляюсь от стены в Medal of Honor:
Как видите, текстуры искажены, и это вызвано аффинным наложением текстур, но наложение меняется, когда я приближаюсь и отдаляюсь, из-за чего всё ещё сильнее колеблется. Что происходит? Чтобы разобраться, мне понадобилось много времени, и пришлось использовать отладчики GPU для анализа данных геометрии и текстур в кадре. Но в конце концов я всё понял!

Геометрия тесселируется и разделяется на лету!

У происходящего есть две причины. Во-первых, это нужно для сглаживания усечения. Усечение на PS1 происходит по примитивам, а не по пикселям, и если рядом с камерой есть крупные полигоны, будет сильно заметно, что они исчезают. Это тоже можно увидеть на примере Tomb Raider в следующем клипе (смотреть с 53:55). Взгляните на правый нижний угол — выходя за экран, полигоны усекаются целиком.


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

Это ещё одна особенность, которую я пока не реализовал в своём рендерере PS1, потому что она опять-таки не сильно влияет на внешний вид, учитывая необходимый для её реализации объём работ.

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


Вершинное освещение


Что ж, двигаемся дальше. Давайте поговорим об освещении. PS1 появилась задолго до программируемых шейдеров, поэтому варианты освещения в основном должны были выбирать разработчики и вариантов этих было не очень много. Растеризатор PS1 предлагал два варианта — плоское затенение (flat shading) и затенение по Гуро (Gouraud shading). Выглядели они так:
Плоское затенение — простейший вид затенения, который можно реализовать. Значения освещения постоянны для каждого примитива, что во времена PS1 сильно повышало производительность. Однако с точки зрения эстетики этого недостаточно, чтобы сделать объекты плавными и реалистичными.
Решением этой проблемы является затенение по Гуро. Оно не особо сложное и вы могли с ним уже встречаться. Это ранняя попытка реализации плавного освещения до того, как можно стало выполнять попиксельные вычисления освещения. В показанном выше видео заметно, что каждая вершина имеет собственный дискретный уровень освещения, и эти значения интерполируются по поверхности полигона. Стоит заметить, что в затенении по Гуро используется та же интерполяция, что и в наложении текстур, поэтому она подвержена тем же артефактам перспективных искажений, однако обычно они гораздо менее заметны.

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

Создание ощущения глубины


Последняя особенность системы рендеринга PS1, которую я хотел воссоздать — это создание ощущения глубины, также называемое туманом. Сегодня он в основном известен благодаря использованию в Silent Hill:
Довольно многие игры не используют эту функцию или туман в них начинается на достаточно далёком расстоянии. Хорошим примером этого является Spyro:
К счастью, с этой функцией разобраться было довольно легко. В современном шейдере она эмулируется вычислением глубины вершины в экранном пространстве и выполнением обратного линейного преобразования для получения плотности тумана, которую мы передаём во фрагментный шейдер.

vec4 depthVert = mul(u_modelView, vec4(a_position, 1.0));
float depth = abs(depthVert.z / depthVert.w);
v_fogDensity.x = 1.0 - clamp((u_fogDepthMax - depth) / (u_fogDepthMin - u_fogDepthMax), 0.0, 1.0);


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

vec3 output = mix(color.rgb, u_fogColor.rgb, v_fogDensity.x);


Объединив всё это с описанными выше техниками, мы получаем готовое изображение, за исключением эффекта ЭЛТ-изображения.

Хотя эффект ЭЛТ-изображения и красив, он мало связан со стилем рендеринга PS1 и добавляется поверх него, поэтому в статье он не рассматривается.


Теперь мы достаточно чётко понимаем, как воссоздать внешний вид игры для PS1, и моя вымышленная консоль должна обернуть её в какой-то API, который можно будет использовать. И здесь у меня возникает большая свобода творчества. Я решил, что не хочу воссоздавать API PS1, а буду писать нечто более простое и лёгкое в использовании, чтобы людей привлекла моя консоль и они могли начать создавать для неё игры. Поэтому я выбрал нечто более похожее на API фиксированного конвейера.

API фиксированного конвейера — это API рендеринга, в которых нельзя писать исполняемый GPU код; вместо этого вы вызываете фиксированные точки входа, заставляющие GPU выполнять заранее заданные процессы. Так работают старые API OpenGL, а также API рендеринга некоторые старых консолей. В Polybox будет использоваться такой API, а внутри он будет применять современный API рендеринга для отрисовки того, что мы опишем при помощи API фиксированного конвейера.

Вот как можно отрендерить один треугольник с применёнными к нему преобразованиями:

SetMatrixMode(MatrixMode::View);
Identity();
Translate(Vec3f(0.0f, 0.0f, -10.0f));

BeginObject(PrimitiveType::Triangles);
  Vertex(Vec3f(1.f, 1.f, 0.0f));
  Vertex(Vec3f(1.f, 2.0f, 0.0f));
  Vertex(Vec3f(2.0f, 2.0f, 0.0f));
EndObject();


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

// Basic draw
void BeginObject(EPrimitiveType type);
void EndObject();
void Vertex(Vec3f vec);
void Color(Vec4f col);
void TexCoord(Vec2f tex);
void Normal(Vec3f norm);
void SetClearColor(Vec3f color);

// Transforms
void MatrixMode(EMatrixMode mode);
void Perspective(float screenWidth, float screenHeight, float nearPlane, float farPlane, float fov);
void Translate(Vec3f translation);
void Rotate(Vec3f rotation);
void Scale(Vec3f scaling);
void Identity();

// Texturing
void BindTexture(const char* texturePath);
void UnbindTexture();

// Lighting
void NormalsMode(ENormalsMode mode);
void EnableLighting(bool enabled);
void Light(int id, Vec3f direction, Vec3f color);
void Ambient(Vec3f color);

// Depth Cueing
void EnableFog(bool enabled);
void SetFogStart(float start);
void SetFogEnd(float end);
void SetFogColor(Vec3f color);


Большинство из этих функций просто изменяет какое-то внутреннее состояние, а не рисует что-то. Само рисование в основном происходит внутри EndObject. Он получает всю информацию о вершинах, предоставленную нами после вызова BeginObject, преобразует её в подходящий формат (например, в буферы вершин и т. п.), и передаёт её в GPU вместе со всеми однородными данными (освещением, информацией о тумане и т. д.). Затем специальные шейдеры отрисовывают их при помощи описанных выше техник, и вуаля — у нас получился кадр. Это чрезвычайно просто, и так сделано намеренно. Аналогично тому, что Pico-8 — это хорошая платформа для новичков в разработке игр, я хочу превратить свою консоль в хорошую платформу для новичков в создании 3D-игр.

Стоит заметить, что код Polybox выложен в open source и вы можете просмотреть код рендеринга и всего остального на странице github.


Я в восторге от Polybox, это очень приятный проект с большим потенциалом. Кроме системы рендеринга, я бы хотел сделать с ним ещё многое, например, реализовать возможность запуска пользовательского кода в VM с аппаратными ограничениями уровня PS1, но это уже задача на будущее. Я считаю, что мы реалистично воссоздали внешний вид графики на PS1, и если вы когда-нибудь захотите написать ретроигру, то надеюсь, моя статья станет полезным введением в тему. Ниже я составил список всех источников, которые помогли мне в исследованиях для статьи и проекта. В них есть очень ценная информация, и многие рассматривают тему более подробно, так что их стоит изучить.

  • PlayStation Architecture — практический анализ оборудования Playstation
  • Making Crash Bandicoot — серия статей разработчика игры
  • PS1 technical specs — полезная статья о спецификациях PS1 в Википедии
  • DevkitPro — набор тулчейнов и API для создания самодельных игр на консоли Nintendo
  • Citro3D — библиотека рендеринга для 3DS, часть DevkitPro
  • PSXSDK — самодельный комплект разработки для PS1
  • GBATEK — огромная веб-страница с подробными техническими спецификациями NDS и GBA
  • Model Resource — хранилище моделей, вырезанных из игр для PS1
  • Dithering on the GPU — интересная статья, к которой я определённо вернусь
  • PSX_retroshader — шейдер для Unity в стиле PS1
  • Retro3DGraphicsCollection — коллекция графических ресурсов в стиле PS1
  • Opengl 1.0 Spec — очень полезная спецификация для воссоздания старомодных API рендеринга
  • Unity forum thread about PS1 style art — хорошее место для знакомства с тем, из чего состоит 3D-арт в стиле PS1.

© Habrahabr.ru