Урок №34. Карта нормалей в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 11 Окт 2020  | 

 1236

Все наши сцены были заполнены мешами, состоящими из сотен или (даже) тысяч треугольников. Мы усилили ощущение реализма сцены, наложив на них 2D-текстуры, маскируя тот факт, что полигоны — это всего лишь крошечные плоские треугольники. Да, использование текстур помогает сделать картинку более привлекательной, но когда вы внимательно смотрите на меши, то все еще довольно легко можно разглядеть лежащие под ними плоские поверхности. Однако большинство реальных поверхностей не являются плоскими объектами и обладают большим количеством различных выпуклых (бугристых) деталей.

Проблема

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

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

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

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

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

Как вы можете видеть, данный метод придает сцене намного более детализированный вид при относительно низких затратах производительности. Поскольку мы изменяем только векторы нормалей фрагментов, то нет необходимости менять уравнения освещения. Теперь вместо интерполированной нормали поверхности алгоритму освещения передается нормаль каждого фрагмента. А уже непосредственно освещение делает всю оставшуюся работу.

Карта нормалей


Чтобы задействовать карту нормалей, нам понадобятся нормали каждого фрагмента. Подобно тому, как мы работали с диффузными и зеркальными картами, мы будем использовать 2D-текстуру для хранения данных по нормалям каждого фрагмента. Таким образом, производя выборку из 2D-текстуры, мы получим нормальный вектор для конкретного фрагмента.

В то время как векторы нормалей являются геометрическими объектами, а текстуры обычно используются только для цветовой информации, способ организации хранения векторов нормалей внутри текстуры может показаться неочевидным. Цветовой вектор текстуры представлен как 3D-вектор с r-, g- и b- компонентами. Аналогичным образом мы можем хранить x-, y- и z- компоненты вектора нормали в соответствующих цветовых компонентах. При этом стоит учитывать, что значения компонентов векторов нормалей задаются диапазоном от -1 до 1, поэтому необходимо сначала отобразить их в диапазоне [0,1]:

Используя нормальные векторы, преобразованные в цветовой диапазон RGB, мы можем хранить в 2D-текстуре вектор нормали каждого фрагмента поверхности. Пример карты нормалей поверхности кирпичной стены (из начала урока) показан ниже:

Эта карта (и почти все карты нормалей, которые вы найдете в интернете) будет иметь синий оттенок. Всё из-за того, что нормали направлены вдоль положительной оси Z с координатами (0,0,1): в результате получается синий цвет. Расхождения в цвете представляют собой векторы нормалей, которые слегка смещены от общего направления вдоль положительной оси Z, придавая текстуре ощущение глубины. Например, вы можете видеть, что в верхней части каждого кирпича цвет имеет тенденцию быть более зеленоватым, что вполне логично, поскольку верхняя сторона кирпича будет иметь нормали, указывающие больше в положительном направлении оси Y с координатами (0,1,0): а это, оказывается, зеленый цвет!

Используя обычную плоскость, смотрящую в сторону положительной оси Z, а также диффузную текстуру,…:

…карту нормалей…:

…мы можем визуализировать изображение из предыдущего параграфа. Обратите внимание, что связанная карта нормалей отличается от той, что показана выше. Причина этого заключается в том, что OpenGL считывает инвертированную Y- (или V-) координату текстуры. Таким образом, связанная карта нормалей содержит инвертированную Y- (или «зеленую») составляющую (вы можете видеть, что зеленые цвета теперь направлены вниз); если вы не примете это во внимание, то освещение будет неправильным. Загрузите обе текстуры, свяжите их с соответствующими текстурными юнитами и визуализируйте плоскость со следующими изменениями во фрагментном шейдере освещения:

Здесь мы инвертируем процесс отображения нормалей в цвета 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):

Сначала мы вычисляем ребра первого треугольника и ΔUΔV-координаты:

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

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

Результирующие касательный и бикасательный векторы должны иметь координаты (1,0,0) и (0,1,0) соответственно, а вместе с вектором нормали (0,0,1) они образуют ортогональную TBN-матрицу. Визуализированные на плоскости TBN-векторы будут выглядеть следующим образом:

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

Наложение карты нормалей в касательном пространстве

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

Затем в рамках функции main() вершинного шейдера мы создаем TBN-матрицу:

Сначала преобразуем все 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-матрицу во фрагментный шейдер очень просто:

Во фрагментном шейдере мы аналогично принимаем mat3 в качестве входной переменной:

С помощью TBN-матрицы мы теперь можем обновить код наложения карты нормалей, чтобы задействовать преобразование координат касательного пространства к координатам мирового пространства:

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

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

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

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

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

Преобразование векторов из мирового пространства в касательное пространство имеет дополнительное преимущество в том, что мы можем преобразовать все соответствующие векторы освещения в касательное пространство в вершинном шейдере, а не во фрагментном шейдере. А всё благодаря тому, что переменные lightPos и viewPos не изменяются при каждом запуске фрагментного шейдера, а для переменной fs_in.FragPos мы можем вычислить положение в касательном пространстве в вершинном шейдере и позволить интерполяции фрагментов сделать свою работу. Фактически нет необходимости преобразовывать вектор в касательное пространство во фрагментном шейдере, в то время как это необходимо в первом подходе, поскольку выборка нормальных векторов специфична для каждого запуска фрагментного шейдера.

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

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

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

Результат:

  Google Drive / Исходный код — Урок №34. Карты нормалей в OpenGL

  GitHub / Исходный код — Урок №34. Карты нормалей в OpenGL

Комплексные объекты


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

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

В Assimp мы можем получить вычисленные касательные векторы через:

Затем вам придется обновить загрузчик моделей, чтобы также загружать карты нормалей из текстурированной модели. Формат .obj-объекта экспортирует карты нормалей в виде, немного отличающемся от соглашений Assimp, поскольку aiTextureType_NORMAL не загружает нормальные карты, в то время как aiTextureType_HEIGHT это делает:

Конечно, это отличается для каждого типа загруженной модели и формата файла.

Запуск приложения на модели с зеркальными картами и картами нормалей, используя обновленный загрузчик моделей, даёт следующий результат:

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

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

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

Процесс ортогонализации Грамма-Шмидта

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

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

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

Это в целом улучшает (с небольшими дополнительными затратами) результаты от наложения карт нормалей. Посмотрите видео Normal Mapping Mathematics в дополнительных ресурсах, чтобы получить отличное объяснение того, как этот процесс на самом деле работает.

Дополнительные ресурсы


   Tutorial 26: Normal Mapping: туториал от ogldev по использованию карт нормалей.

   How Normal Mapping Works: неплохой видеотуториал от TheBennyBox по картам нормалей.

   Normal Mapping Mathematics: аналогичное видео от TheBennyBox про математический аппарат, лежащий в основе наложения карт нормалей.

   Tutorial 13: Normal Mapping: туториал от opengl-tutorial.org.

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (5 оценок, среднее: 4,20 из 5)
Загрузка...

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

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