Хабрахабр

[Перевод] Как работает рендеринг 3D-игр: обработка вершин

image

В этом посте мы рассмотрим этап работы с вершинами. То есть нам придётся снова достать учебники по математике и вспомнить линейную алгебру, матрицы и тригонометрию. Ура!

Также мы подробно объясним разницу между вершинными и геометрическими шейдерами, и вы узнаете, на каком этапе находится место для тесселяции. Мы выясним, как преобразуются 3D-модели и учитываются источники освещения. Чтобы облегчить понимание, мы используем схемы и примеры кода, демонстрирующие, как в игре выполняются вычисления и обрабатываются значения.

Сравните её с намного менее сложным каркасным отображением Half-Life 2. На скриншоте в начале поста показана игра GTA V в каркасном (wireframe) режиме отображения. Изображения созданы thalixte при помощи ReShade.

Что такое точка?

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

На изображении ниже показан скриншот из игры Bethesda 2015 года Fallout 4: Для 3D-графики такая информация критически важна, от неё зависит внешний вид всего, потому что все объекты отображаются как наборы отрезков прямых, плоскостей и т.п.

Возможно, вам будет непросто увидеть, что это всего лишь огромная куча точек и линий, поэтому мы покажем, как та же сцена выглядит в режиме wireframe. В таком режиме движок 3D-рендеринга пропускает текстуры и эффекты, выполняемые на этапе пикселей, и отрисовывает только разноцветные линии, соединяющие точки.

Теперь всё выглядит совершенно иначе, но мы видим, как все линии объединяются, образуя различные объекты, окружения и фон. Некоторые состоят всего лишь из десятков линий, например, камни на переднем плане, другие же содержат столько линий, что выглядят сплошными.

Некоторые вычисления очень просты и быстры, другие намного сложнее. Каждая точка в начале и конце каждой линии обработана выполнением целой кучи вычислений. Обрабатывая точки группами, особенно в виде треугольников, можно добиться значительного роста производительности, поэтому давайте внимательнее приглядимся к ним.

Что же нужно для треугольника?

Название треугольник даёт нам понять, что фигура имеет три внутренних угла; для этого ей нужны три угловых точки и три соединяющих их отрезка. Правильно называть угловую точку вершиной (vertex) (во множественном числе — vertices); каждая вершина задаётся точкой. Так как мы находимся в трёхмерном геометрическом мире, то для точек используется декартова система координат. Обычно координаты записываются в виде трёх значений, например, (1, 8, -3), или обобщённо (x, y, z).

Далее мы можем добавить ещё две вершины, чтобы образовать треугольник:

Учтите, что показанные линии не обязательны — мы можем задать точки и сказать системе, что эти три вершины образуют треугольник. Все данные вершин хранятся в непрерывном блоке памяти, который называется буфером вершин (vertex buffer); информация об образуемой ими фигуре или закодирована непосредственно в программе рендеринга, или хранится в ещё одном блоке памяти, называемом буфером индексов (index buffer).

Direct3D предлагает использовать для них список (list), полосы (strips) и «вееры» (fans) в форме точек, линий и треугольников. Если информация закодирована в программе рендеринга, то различные фигуры, которые могут образованы вершинами, называются примитивами. В показанном ниже примере мы видим, что для создания соединённых вместе двух треугольников нужно всего четыре вершины — если они разделены, то нам понадобится шесть вершин. При правильном использовании полосы треугольников используют вершины более чем для одного треугольника, что позволяет повысить производительность.

Слева направо: список точек, список линий и полоса треугольников

В онлайн-документации Microsoft есть краткое объяснение того, как использовать эти буферы. Если нам нужно обрабатывать больший набор вершин, например, в модели игрового NPC, то лучше использовать объект под названием меш (mesh) — ещё один блок памяти, но состоящий из нескольких буферов (вершин, индексов и т.д.) и ресурсов текстур модели.

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

  • Вершина перемещается в новую позицию
  • Меняется цвет вершины

Готовы к математике? Отлично, потому что она нам и понадобится.

На сцене появляется вектор

Представьте, что у вас на экране есть треугольник и вы нажимаете клавишу, чтобы переместить его влево. Естественно, мы ожидаем, что числа (x, y, z) каждой вершины будут соответствующим образом меняться; так и происходит, однако довольно неожиданно выглядит способ реализации изменений. Вместо простого изменения координат подавляющее большинство систем рендеринга 3D-графики использует особый математический инструмент: мы имеем в виду векторы.

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

Заметьте, что синяя стрелка начинается в одном месте (в данном случае это точка начала координат (origin)) и растягивается до вершины. Для задания вектора мы использовали запись в столбец, но вполне можно применять и запись в строку. Вы могли заметить, что есть ещё одно, четвёртое, значение, обычно называемое w-компонентом. Оно используется для того, чтобы показать, что обозначает вектор: позицию точки (вектор позиции) или общее направление (вектор направления). В случае вектора направления это будет выглядеть следующим образом:

Этот вектор указывает в том же направлении и имеет ту же длину, что и предыдущий вектор позиции, то есть значения (x, y, z) будут такими же; однако w-компонент равен не 1, а нулю. Применение векторов направлений мы объясним позже, а пока запомните тот факт, что все вершины в 3D-сцене будут описываться таким образом. Почему? Потому что в таком формате гораздо проще их перемещать.

Математика, математика, и ещё раз математика

Вспомним, что у нас есть простой треугольник и мы хотим переместить его влево. Каждая вершина описывается вектором позиции, поэтому «математика перемещения» (называемая преобразованиями) должна работать с этими векторами. Появляется новый инструмент: матрицы (matrices) (matrix в единственном числе). Это массив значений, записанный в формате, похожем на электронную таблицу Excel, со строками и столбцами.

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

Перемещение вершины в 3D-пространстве называется переносом (translation) и для него требуется следующий расчёт:

Значения x0, и т.д. представляют исходные координаты вектора; значения delta-x представляют величину, на которую нужно переместить вершину. Перемножение матрицы и вектора приводит к тому, что они просто суммируются (заметьте, что w-компонент остаётся неизменным, чтобы готовый ответ по-прежнему оставался вектором позиции).

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

Это преобразование поворачивает вершину вокруг оси z в плоскости XY

А это используется, если нужно изменить масштаб фигуры

Давайте начнём с прямоугольного параллелепипеда в стандартной позиции: Мы можем воспользоваться графическим инструментом на основе WebGL с сайта Real-Time Rendering, чтобы визуализировать эти вычисления для всей фигуры.

В этом онлайн-инструменте model point является вектором позиции, world matrix — матрицей преобразования, а world-space point — вектором позиции для преобразованной вершины.

Давайте применим к параллелепипеду различные преобразования:

На показанном выше изображении фигура была перенесена на 5 единиц по каждом из осей. Эти значения можно видеть в последнем столбце средней большой матрицы. Исходный вектор позиции (4, 5, 3, 1) остаётся таким же, как и должен, но преобразованная вершина теперь перенесена в (9, 10, 8, 1).

В это преобразовании всё было отмасштабировано на коэффициент 2: теперь стороны параллелепипеда стали в два раза длинее. Наконец, посмотрим на пример поворота:

Проверив на научном калькуляторе, мы можем увидеть, что sin(45°) = 0. Параллелепипед был повёрнут на угол 45°, но в матрице используются синус и косинус этого угла. 71. 7071..., что округляется до показанного значения 0. Тот же ответ мы получим для значения косинуса.

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

Мощь вершинного шейдера

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

Но как всё это выглядит с точки зрения кода?

Если вы хотите самостоятельно начать работу с 3D-программированием, то это подходящее место для изучения основ, а также более сложных вещей… Чтобы понять это, мы воспользуемся примерами с потрясающего веб-сайта Braynzar Soft.

Это пример преобразования «всё в одном». Он создаёт соответствующие матрицы преобразования на основании ввода с клавиатуры, а затем применяет их к исходному вектору позиции за одну операцию. Заметьте, что это всегда выполняется в заданном порядке (масштабирование — поворот — перенос), потому что любой другой способ совершенно испортит результат.

Показанный выше пример прост, это только вершинный шейдер, не использующий полной программируемой природы шейдеров. Такие блоки кода называются вершинными шейдерами (vertex shaders), их сложность и размер могут варьироваться в огромных масштабах. Рассматривая порядок обработки вершин, мы изучим и другие примеры. Более сложная последовательность шейдеров могла бы преобразовывать объекты в 3D-пространстве, обрабатывать их внешний вид с точки зрения камеры сцены, а затем передавать данные на следующий этапе процесса рендеринга.

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

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

Одним из первых процессоров, имеющих собственное аппаратное ускорение данного процесса, был Nvidia GeForce, выпущенный в 2000 году, и эту функциональность назвали Hardware Transform and Lighting (сокращённо Hardware TnL). Процессы, которые могло обрабатывать это оборудование, были очень ограниченными с точки зрения команд, но с выходом новых чипов ситуация быстро менялась. Сегодня не существует отдельного оборудования для обработки вершин и одно устройство занимается всем сразу: точками, примитивами, пикселями, текстурами и т.д.

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

Свет, камера, мотор!

Представьте такую картину: игрок стоит в тёмной комнате, освещённой одним источником света справа. В середине комнаты висит огромный чайник. Возможно, для этого вам понадобится помощь, поэтому давайте воспользуемся веб-сайтом Real-Time Rendering, и увидим, как это будет выглядеть:

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

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

Это ещё одна стрелка, но в отличие от вектора позиции, её размер не важен (на самом деле после вычисления масштаб векторов нормалей всегда уменьшается чтобы они имели длину 1), и она всегда направлена перпендикулярно (под прямым углом) к плоскости. Начнём с того, что нам нужно узнать, куда направлена каждая плоскость, и для этого нам потребуется вектор нормали (normal vector) плоскости.

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

В 3D-рендеринге источники освещения могут быть разного типа, но в этой статье мы будем рассматривать только направленные источники, например, прожекторы. Получив нормаль к поверхности, можно начать учитывать источник освещения и камеру. Как и плоскость треугольника, прожектор и камера будут указывать в определённом направлении, примерно так:

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

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

  • Исходный базовый цвет
  • Атрибут материала Ambient — значение, определяющее, сколько «фонового» освещения может поглотить и отразить вершина
  • Атрибут материала Diffuse — ещё одно значение, но на этот раз определяющее «шероховатость» вершины, что, в свою очередь, влияет на величину поглощения и отражения рассеянного света
  • Атрибуты материала Specular — два значения, задающие величину «блеска» вершины

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

Один направленный источник освещения освещает множество различных материалов из демо Nvidia

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

Пример кода B. Anguelov, показывающий, как в вершинном шейдере можно обрабатывать модель отражения света по Фонгу.

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

Пожалуйста, сэр, мне хочется ещё (треугольников)

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

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

Чтобы посмотреть на это в действии, мы воспользуемся инструментом бенчмарка Heaven движка Unigine, потому что он позволяет нам применять к используемым в тесте моделям различные величины тесселяции.

Для начала давайте возьмём место в бенчмарке и изучим его без применения тесселяции. Заметьте, что булыжники на земле выглядят очень неестественными — использованная текстура эффективна, но кажется неправильной. Давайте применим к сцене тесселяцию: движок Unigine применяет её только к отдельным частям, но различие будет значительным.

Земля, края зданий и дверь выглядят намного реалистичнее. Мы можем увидеть, как это было достигнуто, запустив процесс заново, но на этот раз с выделением всех примитивов (то есть в режиме wireframe):

Чётко видно, почему земля выглядит так странно — она совершенно плоская! Дверь сливается со стенами, а края здания представляют собой простые параллелепипеды.

Сначала программисты пишут шейдер поверхности (hull shader) — по сути, этот код создаёт структуру под названием патч геометрии (geometry patch). В Direct3D примитивы можно разделить на группу из более мелких частей (этот процесс называется подразделением (sub-division)), выполнив трёхэтапный процесс. Можно воспринимать её как карту, сообщающую процессору, где внутри начального примитива будут появляться новые точки и линии.

В конце выполняется доменный шейдер (domain shader), вычисляющий позиции всех новых вершин. Затем блок-тесселятор внутри графического процессора применяет этот патч к примитиву. Эти данные при необходимости можно передать обратно в буфер вершин, чтобы можно было заново выполнить вычисления освещения, но на этот раз с более качественными результатами.

Давайте запустим каркасную версию тесселированной сцены: Как же это выглядит?

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

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

Давайте посмотрим, как это может выглядеть с точки зрения кода Direct3D; для этого мы используем пример с ещё одного замечательного веб-сайта, RasterTek.

Вот простой зелёный треугольник, тесселированный на множество крошечных треугольничков…

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

«Железо» этого не выдержит!

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

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

UL Benchmark's 3DMark Vantage — геометрические шейдеры обрабатывают частицы и флаги

Готовые данные могут быть или переданы на следующий этап процесса рендеринга (растеризацию), или вернуться в пул памяти для повторной обработки или считывания центральным процессором для других целей. Direct3D, как и все современные графические API, позволяет выполнять с вершинами большое множество вычислений. Как сказано в документации Microsoft по Direct3D, это можно реализовать как поток данных:

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

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

Треугольники. Их миллионы.

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

А ведь это только самое начало построения 3D-кадра — следующим идёт этап растеризации, затем чрезвычайно сложная обработка пикселей и текстур, и уже потом изображение попадает на монитор. Опытные программисты рендеринга 3D-игр имеют фундаментальные знания в области математики и физики; они используют все возможные трюки и инструменты для оптимизации операций, сжимая этап обработки вершин всего до нескольких миллисекунд.

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

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

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

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

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