Все наши сцены были заполнены мешами, состоящими из сотен или (даже) тысяч треугольников. Мы усилили ощущение реализма сцены, наложив на них 2D-текстуры, маскируя тот факт, что полигоны — это всего лишь крошечные плоские треугольники. Да, использование текстур помогает сделать картинку более привлекательной, но когда вы внимательно смотрите на меши, то все еще довольно легко можно разглядеть лежащие под ними плоские поверхности. Однако большинство реальных поверхностей не являются плоскими объектами и обладают большим количеством различных выпуклых (бугристых) деталей.
Проблема
Возьмем, к примеру, кусок кирпичной стены. Данная поверхность довольно шероховатая и, очевидно, не совсем плоская: она содержит впалые цементные полосы и множество маленьких детализированных дырок и трещин. Если бы мы рассматривали такую кирпичную поверхность в освещенной сцене, то её внешний вид мог бы легко разрушить веру в реальность подобной стены. Ниже мы видим текстуру кирпичной стены, нанесенную на плоскую поверхность и освещенную точечным источником света:
Система освещения не принимает во внимание мелкие трещины и отверстия в стене и полностью игнорирует глубокие полосы между кирпичами; поверхность выглядит идеально плоской. Используя зеркальную карту, мы можем частично исправить данную ситуацию, делая некоторые части поверхности менее освещенными из-за их глубины или наличия рядом других деталей, но данный метод больше похож на халтуру, нежели на реальное решение проблемы. Что нам нужно, так это каким-то образом проинформировать систему освещения обо всех мелких глубинных деталях поверхности.
Давайте рассмотрим описываемую ситуацию с точки зрения источника света: как получается, что данная поверхность освещается как абсолютно плоская? Ответ на этот вопрос кроется в её векторах нормали. С точки зрения техники освещения, единственный способ определить форму объекта — это использовать его вектор нормали. Кирпичная поверхность имеет только один выраженный вектор нормали, и в результате, в зависимости от его направления, она равномерно освещается. Что, если вместо вектора нормали к поверхности, который является одинаковым для каждого её фрагмента, мы будем использовать векторы нормали каждого фрагмента? Таким образом, мы можем слегка отклонить вектор нормали, основываясь на мелких деталях поверхности; создать иллюзию того, что внешний вид поверхности будет выглядеть более проработанным:
Используя нормали каждого фрагмента, мы можем обмануть освещение, заставив его поверить в то, что поверхность состоит из крошечных плоскостей (перпендикулярных нормальным векторам), придающих ей бОльшую детализацию. Данный метод использования нормалей каждого фрагмента вместо нормали поверхности называется картой нормалей.
Применительно к кирпичной стене это выглядит следующим образом:
Как вы можете видеть, данный метод придает сцене намного более детализированный вид при относительно низких затратах производительности. Поскольку мы изменяем только векторы нормалей фрагментов, то нет необходимости менять уравнения освещения. Теперь вместо интерполированной нормали поверхности алгоритму освещения передается нормаль каждого фрагмента. А уже непосредственно освещение делает всю оставшуюся работу.
Карты нормалей
Чтобы задействовать карту нормалей, нам понадобятся нормали каждого фрагмента. Подобно тому, как мы работали с диффузными и зеркальными картами, мы будем использовать 2D-текстуру для хранения данных о нормалях каждого фрагмента. Таким образом, производя выборку из 2D-текстуры, мы получим нормальный вектор для конкретного фрагмента.
В то время как векторы нормалей являются геометрическими объектами, а текстуры обычно используются только для цветовой информации, способ организации хранения векторов нормалей внутри текстуры может показаться неочевидным. Цветовой вектор текстуры представлен как 3D-вектор с r-, g- и b- компонентами. Аналогичным образом мы можем хранить x-, y- и z- компоненты вектора нормали в соответствующих цветовых компонентах. При этом стоит учитывать, что значения компонентов векторов нормалей задаются диапазоном от -1
до 1
, поэтому необходимо сначала отобразить их в диапазоне [0,1]
:
1 |
vec3 rgb_normal = normal * 0.5 + 0.5; // преобразование из диапазона [-1,1] в диапазон [0,1] |
Используя нормальные векторы, преобразованные в цветовой диапазон RGB, мы можем хранить в 2D-текстуре вектор нормали каждого фрагмента поверхности. Пример карты нормалей поверхности кирпичной стены (рассматривалась в начале урока) показан ниже:
Эта карта (и почти все карты нормалей, которые вы найдете в интернете) будет иметь синий оттенок. Всё из-за того, что нормали направлены вдоль положительной оси z с координатами (0,0,1)
: в результате получается синий цвет. Расхождения в цвете представляют собой векторы нормалей, которые слегка смещены от общего направления вдоль положительной оси z, придавая текстуре ощущение глубины. Например, вы можете видеть, что в верхней части каждого кирпича цвет имеет тенденцию быть более зеленоватым, что вполне логично, поскольку верхняя сторона кирпича будет иметь нормали, указывающие больше в положительном направлении оси y с координатами (0,1,0)
: а это, оказывается, зеленый цвет!
Используя обычную плоскость, смотрящую в сторону положительной оси z, а также диффузную текстуру,…:
…карту нормалей…:
…мы можем визуализировать изображение из предыдущего параграфа. Обратите внимание, что связанная карта нормалей отличается от той, что показана выше. Причина этого заключается в том, что OpenGL считывает инвертированную y- (или v-) координату текстуры. Таким образом, связанная карта нормалей содержит инвертированную y- (или «зеленую») составляющую (вы можете видеть, что зеленые цвета теперь направлены вниз); если вы не примете это во внимание, то освещение будет неправильным. Загрузите обе текстуры, свяжите их с соответствующими текстурными юнитами и визуализируйте плоскость со следующими изменениями во фрагментном шейдере освещения:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
uniform sampler2D normalMap; void main() { // Получаем нормаль из карты нормалей с диапазоном [0,1] normal = texture(normalMap, fs_in.TexCoords).rgb; // Переводим вектор нормали в диапазон [-1,1] normal = normalize(normal * 2.0 - 1.0); [...] // Работаем со светом как обычно } |
Здесь мы инвертируем процесс отображения нормалей в цвета RGB, переназначая выборку нормального цвета из диапазона [0,1]
обратно в диапазон [-1,1]
, а затем используем выборку нормальных векторов для предстоящих расчетов освещения. В данном случае мы использовали шейдер Блинна-Фонга.
Используя карты нормалей и медленно перемещая с течением времени источник света, мы получаем ощущение сцены. Запуск данного примера дает результаты, которые были показаны в начале этого урока:
Однако есть одна проблема, которая значительно ограничивает использование карты нормалей. Карта нормалей, с которой мы работали, имела векторы нормалей, указывающие в положительном направлении оси z. И всё было хорошо, т.к. нормаль поверхности плоскости также указывала в положительном направлении оси z. Однако, что произойдет, если мы будем использовать одну и ту же карту нормалей для плоскости, лежащей на земле, с вектором нормали, указывающим в положительном направлении оси y?
Освещение выглядит неправильным! Это происходит потому, что прошедшие выборку нормали все еще указывают (в основном) в положительном направлении оси z, хотя они должны указывать (в своем большинстве) в положительном направлении оси y. В результате освещение рассчитано исходя из ложного предположения, что векторы нормали поверхности такие же, как и раньше, когда плоскость была направлена в положительном направлении оси z; освещение получилось некорректным. На следующем рисунке примерно показано, как сэмплированные векторы нормали выглядят на этой поверхности:
Вы можете видеть, что в целом все нормали указывают в положительном направлении оси z, хотя они должны указывать в сторону положительного направления оси y. Одним из решений данной проблемы является определение карты нормалей для каждого возможного направления поверхности; в случае куба нам понадобилось бы шесть карт нормалей. Однако при использовании продвинутых мешей, которые могут иметь более сотни возможных направлений поверхности, такой подход становится неосуществимым.
Есть другое решение, которое приводит всё освещение к другому координатному пространству, в котором векторы карты нормалей всегда указывают в положительном направлении оси z, а все другие векторы освещения преобразуются относительно данного положительного направления оси z. Таким образом, мы всегда можем использовать одну и ту же карту нормалей, независимо от ориентации поверхности. Описываемое координатное пространство называется касательным пространством.
Касательное пространство
Векторы нормалей из карты нормалей определены в касательном пространстве, в котором нормали всегда указывают (примерно) в положительном направлении оси z. Касательное пространство — это пространство, локальное по отношению к поверхности треугольника: нормали задаются относительно локальной системы отсчета отдельных треугольников. Думайте об этом как о локальном пространстве векторов карты нормалей; все они определены в положительном направлении оси z независимо от конечного преобразованного направления. Используя соответствующую матрицу, мы можем преобразовать нормальные векторы из этого локального касательного пространства в координаты мирового пространства или пространства вида, ориентируя их вдоль направления результирующей поверхности.
Давайте рассмотрим пример поверхности из предыдущего раздела, ориентированной в положительном направлении оси y и с некорректным эффектом от нанесенной карты нормалей. Карта нормалей определяется в касательном пространстве, поэтому один из способов решить ранее упомянутую проблему — это вычислить матрицу для преобразования нормалей из касательного пространства в такое пространство, в котором векторы были бы выровнены вдоль нормалей поверхности; тогда все нормальные векторы будут направлены (примерно) в положительном направлении оси y. Самое замечательное в касательном пространстве то, что мы можем вычислить упомянутую матрицу для любого типа поверхности, чтобы правильно выровнять z-направление касательного пространства вдоль направления нормали поверхности.
Такая матрица называется TBN-матрицей (сокр. от «Tangent, Bitangent, Normal векторы» — это те векторы, которые нам нужны для построения искомой матрицы). Чтобы построить такую матрицу изменения базиса, которая преобразует вектор касательного пространства в другое координатное пространство, нам нужны три перпендикулярных вектора, два из которых выровнены вдоль поверхности карты нормалей, а третий перпендикулярен ей (аналогично тому, что мы делали на уроке о камере в OpenGL).
Мы уже знаем вектор-вверх, который является вектором нормали поверхности. Вектор-вправо и вектор-вперед являются касательным и бикасательным векторами соответственно. На следующем изображении поверхности показаны все три вектора:
Процесс вычисления касательного и бикасательного векторов является немного более сложным по сравнению с вычислением нормального вектора. Из рисунка видно, что направления касательного и бикасательного векторов карты нормалей совпадают с направлениями, в которых мы определяем координаты текстуры поверхности. Мы будем использовать данный факт для вычисления касательного и бикасательного векторов для каждой поверхности. Для получения данных векторов нам потребуются некоторые знания из математики. Взгляните на следующее изображение:
Из рисунка видно, что изменения текстурных координат (обозначены как ΔU2
и ΔV2
) ребра E2
треугольника выражаются вдоль направления касательного вектора T и бикасательного вектора B соответственно. Поэтому мы можем выразить оба ребра E1
и E2
треугольника через линейную комбинацию касательного вектора T и бикасательного вектора B:
Что также может быть записано в координатном виде:
Мы можем вычислить E как вектор разности между двумя точками треугольника, а ΔU
и ΔV
— как разности их текстурных координат. Тогда мы остаемся с двумя неизвестными переменными (касательным вектором T и бикасательным вектором B) и двумя уравнениями. Из курса алгебры известно, что в таком случае мы можем получить решение уравнения и для T, и для B.
Последние уравнения позволяют нам переписать их в форме умножения матриц:
Попробуйте мысленно представить себе умножение матриц и убедитесь, что это действительно одно и то же уравнение. Преимущество переписывания уравнений в матричном виде состоит в том, что нам будет легче понять нахождение решения для T и для B. Если мы умножим обе стороны уравнений на обратную ΔUΔV
-матрицу, то получим следующее:
Я не буду вдаваться в математические детали вычисления обратной матрицы, скажу лишь, что она равняется единице, деленной на определитель матрицы и умноженной на её смежную матрицу:
Итоговое уравнение дает нам формулу для вычисления касательного вектора T и бикасательного вектора B на основе двух ребер треугольника и его текстурных координат.
Не волнуйтесь, если вы не до конца понимаете изложенную математику, стоящую за данными формулами. До тех пор, пока вы понимаете, что мы можем вычислять касательные и бикасательные векторы из вершин треугольника и его текстурных координат (поскольку текстурные координаты находятся в том же пространстве, что и касательные векторы), то вы на верном пути к цели.
Ручной расчет касательного и бикасательного векторов
В предыдущей программе у нас была обычная плоскость, ориентированная в положительном направлении оси z и с наложенной на нее картой нормалей. На этот раз мы хотим реализовать наложение карты нормалей с использованием касательного пространства, чтобы мы могли ориентировать эту плоскость так, как мы хотим, и при этом карта нормалей все равно бы работала как надо. Используя ранее рассмотренную математическую теорию, мы собираемся вручную вычислить касательные и бикасательные векторы этой поверхности.
Предположим, что плоскость построена на следующих векторах (образующих два треугольника: 1, 2, 3 и 1, 3, 4):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// Координаты glm::vec3 pos1(-1.0, 1.0, 0.0); glm::vec3 pos2(-1.0, -1.0, 0.0); glm::vec3 pos3( 1.0, -1.0, 0.0); glm::vec3 pos4( 1.0, 1.0, 0.0); // Текстурные координаты glm::vec2 uv1(0.0, 1.0); glm::vec2 uv2(0.0, 0.0); glm::vec2 uv3(1.0, 0.0); glm::vec2 uv4(1.0, 1.0); // Векторы нормалей glm::vec3 nm(0.0, 0.0, 1.0); |
Сначала мы вычисляем ребра первого треугольника и ΔUΔV
-координаты:
1 2 3 4 |
glm::vec3 edge1 = pos2 - pos1; glm::vec3 edge2 = pos3 - pos1; glm::vec2 deltaUV1 = uv2 - uv1; glm::vec2 deltaUV2 = uv3 - uv1; |
Имея необходимые данные для вычисления касательных и бикасательных векторов, мы можем перейти к работе с уравнением из предыдущего параграфа:
1 2 3 4 5 6 7 8 9 10 11 |
float f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y); tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x); tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y); tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z); bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x); bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y); bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z); [...] // аналогичные действия для вычисления касательного/бикасательного векторов второго треугольника плоскости |
В вышеописанном фрагменте кода мы сначала вычисляем дробную часть уравнения, для простоты обозначенную как f
, а затем для каждой векторной компоненты делаем соответствующее матричное умножение, которое и умножаем на f
. Если вы сравните этот код с финальным уравнением, то увидите, что он полностью ему соответствует. Поскольку треугольник всегда имеет плоскую форму, то нам нужно вычислить только одну пару касательных/бикасательных векторов для каждого треугольника, так как они будут одинаковы для каждой вершины треугольника.
Результирующие касательный и бикасательный векторы должны иметь координаты (1,0,0)
и (0,1,0)
соответственно, а вместе с вектором нормали (0,0,1)
они образуют ортогональную TBN-матрицу. Визуализированные на плоскости TBN-векторы будут выглядеть следующим образом:
С помощью касательных и бикасательных векторов, определенных для каждой вершины, мы можем начать реализацию правильного наложения карты нормалей.
Наложение карты нормалей в касательном пространстве
Для того, чтобы произвести правильное наложение карты нормалей, мы сначала должны создать в шейдерах TBN-матрицу. Для этого мы передаем ранее вычисленные касательный и бикасательный векторы вершинному шейдеру в качестве атрибутов вершин:
1 2 3 4 5 6 |
#version 330 core layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; layout (location = 2) in vec2 aTexCoords; layout (location = 3) in vec3 aTangent; layout (location = 4) in vec3 aBitangent; |
Затем в рамках функции main() вершинного шейдера мы создаем TBN-матрицу:
1 2 3 4 5 6 7 8 |
void main() { [...] vec3 T = normalize(vec3(model * vec4(aTangent, 0.0))); vec3 B = normalize(vec3(model * vec4(aBitangent, 0.0))); vec3 N = normalize(vec3(model * vec4(aNormal, 0.0))); mat3 TBN = mat3(T, B, N); } |
Сначала преобразуем все TBN-векторы в систему координат, в которой мы хотели бы работать — в данном случае, это мировое пространство (поскольку мы умножаем векторы на матрицу model
). Затем мы создаем фактическую TBN-матрицу, непосредственно передавая конструктору mat3
соответствующие векторы. Обратите внимание, для получения более корректных вычислений нам нужно умножать TBN-векторы на матрицу нормалей.
Примечание: Технически нет никакой необходимости в бикасательной переменной bitangent
вершинного шейдера. Все три TBN-вектора перпендикулярны друг другу, поэтому мы можем вычислить bitangent
вектор в вершинном шейдере сами, используя векторное произведение векторов T
и N
: vec3 B = cross(N, T);
.
Итак, теперь, когда у нас есть TBN-матрица, как мы собираемся её использовать? Существует два способа использования TBN-матрицы для наложения карты нормалей:
Способ №1: Мы берем TBN-матрицу, которая преобразует любой вектор из касательного пространства в мировое пространство, передаем её во фрагментный шейдер и преобразуем выбранные нормали из касательного пространства в мировое пространство с помощью TBN-матрицы. После чего, нормаль будет находиться в том же пространстве, что и другие переменные освещения.
Способ №2: Мы берем обратную TBN-матрицу, которая преобразует любой вектор из мирового пространства в касательное пространство, и используем эту матрицу для преобразования не нормали, а других соответствующих переменных освещения в касательное пространство; нормаль останется в том же пространстве, а другие переменные освещения переместятся в одно пространство с нормалью.
Давайте рассмотрим первый способ. Вектор нормали, который мы выбираем из карты нормалей, определяется в касательном пространстве, тогда как другие векторы освещения (направления света и вида) определяются в мировом пространстве. Передавая TBN-матрицу во фрагментный шейдер, мы можем умножить эту TBN-матрицу на выбранную нормаль касательного пространства, чтобы преобразовать вектор нормали в то же пространство, что и другие векторы освещения. Таким образом, все расчеты освещения (в частности, скалярное умножение) будут корректны.
Отправить TBN-матрицу во фрагментный шейдер очень просто:
1 2 3 4 5 6 7 8 9 10 11 |
out VS_OUT { vec3 FragPos; vec2 TexCoords; mat3 TBN; } vs_out; void main() { [...] vs_out.TBN = mat3(T, B, N); } |
Во фрагментном шейдере мы аналогично принимаем mat3
в качестве входной переменной:
1 2 3 4 5 |
in VS_OUT { vec3 FragPos; vec2 TexCoords; mat3 TBN; } fs_in |
С помощью TBN-матрицы мы теперь можем обновить код наложения карты нормалей, чтобы задействовать преобразование координат касательного пространства в координаты мирового пространства:
1 2 3 |
normal = texture(normalMap, fs_in.TexCoords).rgb; normal = normal * 2.0 - 1.0; normal = normalize(fs_in.TBN * normal); |
Поскольку итоговая переменная normal
теперь находится в мировом пространстве, то нет необходимости изменять какой-либо другой фрагмент кода шейдера, так как код освещения предполагает, что вектор нормали находится в мировом пространстве.
Рассмотрим также второй случай, когда мы берем обратную TBN-матрицу для преобразования всех соответствующих векторов мирового пространства в пространство, в котором находятся выбранные нормальные векторы — касательное пространство. Построение TBN-матрицы остается прежним, но мы сначала инвертируем матрицу, прежде чем отправить её во фрагментный шейдер:
1 |
vs_out.TBN = transpose(mat3(T, B, N)); |
Обратите внимание, что здесь мы используем функцию транспонирования transpose() вместо функции инверсии inverse(). Большой плюс ортогональных матриц (каждая ось является перпендикулярной единичному вектору) состоит в том, что транспонирование ортогональной матрицы эквивалентно её инвертированию. Это отличное свойство, так как инверсия стоит дорого, а транспонирование — нет.
Внутри фрагментного шейдера мы не трогаем нормальный вектор, а преобразуем другие соответствующие векторы в касательное пространство, а именно, векторы lightDir
и viewDir
. Таким образом, все векторы преобразуются в одно и то же координатное пространство — касательное пространство.
1 2 3 4 5 6 7 8 9 |
void main() { vec3 normal = texture(normalMap, fs_in.TexCoords).rgb; normal = normalize(normal * 2.0 - 1.0); vec3 lightDir = fs_in.TBN * normalize(lightPos - fs_in.FragPos); vec3 viewDir = fs_in.TBN * normalize(viewPos - fs_in.FragPos); [...] } |
Второй подход выглядит более трудоемким и также требует умножения матриц во фрагментном шейдере, так зачем же нам его рассматривать?
Преобразование векторов из мирового пространства в касательное пространство имеет дополнительное преимущество в том, что мы можем преобразовать все соответствующие векторы освещения в касательное пространство в вершинном шейдере, а не во фрагментном шейдере. А всё благодаря тому, что переменные lightPos
и viewPos
не изменяются при каждом запуске фрагментного шейдера, а для переменной fs_in.FragPos
мы можем вычислить положение в касательном пространстве в вершинном шейдере и позволить интерполяции фрагментов сделать свою работу. Фактически, нет необходимости преобразовывать вектор в касательное пространство во фрагментном шейдере, в то время как это необходимо при первом подходе, поскольку выборка нормальных векторов специфична для каждого запуска фрагментного шейдера.
Поэтому вместо того, чтобы передавать фрагментному шейдеру обратную TBN-матрицу, мы передаем положение света в касательном пространстве, положение вида и положение вершины фрагментному шейдеру. Это избавляет нас от необходимости выполнять умножение матриц во фрагментном шейдере. В результате получается хорошая оптимизация, так как вершинный шейдер работает значительно реже, чем фрагментный шейдер. Это также является причиной того, почему данный подход часто является более предпочтительным:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
out VS_OUT { vec3 FragPos; vec2 TexCoords; vec3 TangentLightPos; vec3 TangentViewPos; vec3 TangentFragPos; } vs_out; uniform vec3 lightPos; uniform vec3 viewPos; [...] void main() { [...] mat3 TBN = transpose(mat3(T, B, N)); vs_out.TangentLightPos = TBN * lightPos; vs_out.TangentViewPos = TBN * viewPos; vs_out.TangentFragPos = TBN * vec3(model * vec4(aPos, 0.0)); } |
Затем во фрагментном шейдере мы используем вышеуказанные новые входные переменные для расчета освещения в касательном пространстве. Поскольку нормальный вектор уже находится в касательном пространстве, то данные вычисления являются корректными.
При использовании карты нормалей в касательном пространстве мы должны получить аналогичные результаты, которые имели в начале данного урока. Однако на этот раз мы можем ориентировать нашу плоскость так, как нам хочется, и освещение все равно будет правильным:
1 2 3 4 |
glm::mat4 model = glm::mat4(1.0f); model = glm::rotate(model, (float)glfwGetTime() * -10.0f, glm::normalize(glm::vec3(1.0, 0.0, 1.0))); shader.setMat4("model", model); RenderQuad(); |
Результат:
GitHub / Урок №34. Карта нормалей в OpenGL — Исходный код
Комплексные объекты
Мы продемонстрировали, как можно использовать карту нормалей вместе с преобразованиями касательного пространства, вычисляя вручную касательные и бикасательные векторы. К счастью для нас, необходимость вручную вычислять данные векторы возникает не слишком часто. В большинстве случаев подобный код реализуется один раз в пользовательском загрузчике моделей или (в нашем случае) в используемом загрузчике моделей Assimp.
Assimp имеет очень полезный бит конфигурации, который мы можем установить при загрузке модели, под названием aiProcess_CalcTangentSpace
. Когда бит aiProcess_CalcTangentSpace
передается в функцию ReadFile(), то Assimp вычисляет касательные и бикасательные векторы для каждой из загруженных вершин (аналогично тому, как мы это делали):
1 2 3 |
const aiScene *scene = importer.ReadFile( path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace ); |
В Assimp мы можем получить вычисленные касательные векторы через:
1 2 3 4 |
vector.x = mesh->mTangents[i].x; vector.y = mesh->mTangents[i].y; vector.z = mesh->mTangents[i].z; vertex.Tangent = vector; |
Затем нам придется обновить загрузчик моделей, чтобы также загружать карты нормалей из текстурированной модели. Формат .obj-объекта экспортирует карты нормалей в виде, немного отличающемся от соглашений Assimp, поскольку aiTextureType_NORMAL
не загружает нормальные карты, в то время как aiTextureType_HEIGHT
это делает:
1 |
vector<Texture> normalMaps = loadMaterialTextures(material, aiTextureType_HEIGHT, "texture_normal"); |
Вышеописанный метод не является универсальным, и зависит от типа загружаемой модели и формата файла.
Запуск приложения на модели с зеркальными картами и картами нормалей, используя обновленный загрузчик моделей, дает следующий результат:
Как вы можете видеть, наложение карты нормалей поднимает детализацию объекта на совершенно новый уровень, при этом без серьезных дополнительных вычислений.
Использование карт нормалей также является отличным способом повысить производительность. Раньше для получения большого количество деталей на меше приходилось задействовать большее количество вершин. При наложении карты нормалей мы можем получить тот же уровень детализации меша, задействуя при этом гораздо меньше вершин. Ниже представлена картинка от Paolo Cignoni, которая демонстрирует применение обоих методов:
Как вы можете видеть, детализация объекта слева почти неотличима от детализации объекта справа. Таким образом, результат применения карт нормалей не только выглядит красиво, но и является отличным инструментом для замены высокополигональных мешей на низкополигональные без (ощутимой) потери детализации.
Процесс ортогонализации Грама-Шмидта
Когда касательные векторы вычисляются на больших мешах, разделяющих значительное количество вершин, касательные векторы обычно усредняются, чтобы дать хороший и гладкий результат. Проблема с данным подходом заключается в том, что три TBN-вектора могут оказаться неперпендикулярными, а это означает, что результирующая TBN-матрица больше не будет ортогональной. В таком случае результат наложения карты нормалей с использованием неортогональной TBN-матрицы был бы немного некорректным, но мы можем это исправить.
Используя математический трюк, называемый процессом ортогонализации Грама-Шмидта, мы можем повторно ортогонализировать TBN-векторы таким образом, что каждый вектор снова будет перпендикулярен другим векторам. Внутри вершинного шейдера мы бы сделали это следующим образом:
1 2 3 4 5 6 7 8 9 10 |
vec3 T = normalize(vec3(model * vec4(aTangent, 0.0))); vec3 N = normalize(vec3(model * vec4(aNormal, 0.0))); // Реортогонализация вектора T относительно вектора N T = normalize(T - dot(T, N) * N); // Затем получаем перпендикулярный вектор B в виде результата векторного умножения векторов T и N vec3 B = cross(N, T); mat3 TBN = mat3(T, B, N) |
Это в целом улучшает (с небольшими дополнительными затратами) результаты от наложения карт нормалей. Посмотрите видео Normal Mapping Mathematics (из дополнительных ресурсов), чтобы получить отличное объяснение того, как этот процесс на самом деле работает.
Дополнительные ресурсы
Tutorial 26: Normal Mapping — статья от ogldev об использовании карт нормалей.
How Normal Mapping Works — неплохой видеотуториал от TheBennyBox о картах нормалей.
Normal Mapping Mathematics — аналогичное видео от TheBennyBox про математический аппарат, лежащий в основе наложения карт нормалей.
Tutorial 13: Normal Mapping — туториал от opengl-tutorial.org.
Ух, пришлось повозиться…
Много времени отняло корректрное сопоставление вершинных и текстурными координат куба (для четкого положения верх-низ лево-право). Пришлось вспомнить урок по геометрическим шейдерам (отобразить векторы нормалей, касательных бикасательных).
Применение одного шедера на разнотипные объекты (с разным количеством разных текстур) требует особого внимания. Если в объектах разные наборы текстур то сэплеры привязываются не всегда корректно. Лучше, наверное, писать отдельные шейдеры.
Результат вроде бы удовлетворительный: https://www.youtube.com/watch?v=7Tm5RdVXJ98