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

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

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

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

Polybox!

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

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

Благодаря чему игры для PS1 выглядят как игры для PS1?

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

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

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

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

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

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

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

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

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

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

Crash Bandicoot

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Думаю, почти все помнят, что на 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 является отсутствие буфера глубин, имеющее серьёзные последствия, о которых мы и поговорим. Во-первых, если вы не знаете, что такое буфер глубин, то сообщу, что это отображение кадра в оттенках серого, в котором хранится «значение» глубины для каждого пикселя, сообщающее, насколько далеко он находится от камеры.

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

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, и её работа показана в клипе выше. Почему же она не работает? Причина в перспективе. Давайте возьмём четырёхугольник из видео, и взглянем на него сверху и под углом.

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

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

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

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

Как это исправить? В современных 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}}}$

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

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

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

Однако при работе с 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 и добавляется поверх него, поэтому в статье он не рассматривается.

Проектирование более современного API рендеринга

Теперь мы достаточно чётко понимаем, как воссоздать внешний вид игры для 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.


 

Источник

Читайте также