normal mapping что это
Это норма: что такое карты нормалей и как они работают
На протяжении нескольких лет я пытался разобраться в картах нормалей и в проблемах, которые обычно возникают при работе с ними.
Большинство найденных объяснений было слишком техническим, неполным или чересчур сложным для моего понимания, поэтому я решил попробовать объяснить собранную мной информацию. Я понимаю, что эти объяснения могут быть неполными или не совсем точными, но всё равно попробую.
Первые созданные человеком 3D-модели выглядели примерно так:
Это замечательно, но у такой модели есть очевидное ограничение: она выглядит слишком полигональной.
Наиболее очевидное решение: добавить больше полигонов, сделав поверхность более равномерной и гладкой, вплоть до того, чтобы полигоны казались единой гладкой поверхностью. Но оказывается, для того, чтобы сделать поверхности наподобие сфер гладкими, нужно огромное количество полигонов (особенно сегодня).
Требовалось другое решение, и так были изобретены нормали. (Всё происходило не совсем так, но так проще объяснять и понимать.)
Давайте проследим за линией из центра полигона, перпендикулярной его поверхности. Мы дадим этой линии очень непривычное название: нормаль. Цель нормали — контролировать, куда указывает поверхность, чтобы когда свет отразиться от этой поверхности, она могла использовать нормаль для вычисления получившегося отражения. Когда свет падает на полигон, мы сравниваем угол луча света с нормалью полигона. Луч отражается под тем же углом относительно направления нормали:
Другими словами, отражение света будет симметрично относительно нормали полигона. Именно так работает большинство отражений в реальном мире. По умолчанию лучи света отражаются от всех полигонов совершенно перпендикулярно к их поверхности (как должны это делать в реальной жизни), потому что нормали полигона по умолчанию перпендикулярны к поверхности полигона. Если в нормалях будут пробелы, то мы увидим их как отдельные поверхности, поскольку свет отразится в одном или другом направлении.
Если две грани соединены, то мы можем попросить компьютер сгладить переход между нормалью одного полигона к другому, чтобы нормали постепенно выстраивались в соответствии с ближайшей нормалью полигона. Таким образом, когда свет попадёт ровно в центр одного полигона, то он отразится прямо, в соответствии с направлением нормали. Но между полигонами это направление нормали сглаживается, изменяя отражение света.
Мы будем воспринимать переход как единую поверхность, потому что свет будет отражаться между одним и другим полигоном плавным образом, и между ними не будет пробелов. По сути, свет отражается от этих полигонов плавно, как будто у нас имеется множество полигонов.
Именно этим мы управляем, задавая smoothing groups (3ds Max, Blender) или указывая рёбра как hard или smooth (Modo, Maya): мы сообщаем программе, какие переходы между гранями должны быть плавными, а какие — жёсткими.
Вот сравнение одной сферы из 288 полигонов с жёсткими и плавными переходами:
Потенциально мы можем задать нечто вроде параллелепипеда, чтобы все его вершины имели усреднённые нормали. 3D-редактор будет стремиться сгладить его поверхность, чтобы она выглядела как единая плавная поверхность. Для 3D-редактора это вполне логично, но выглядит очень странно, потому что у нас есть объект, который очевидно должен иметь несколько отдельных поверхностей (каждая грань параллелепипеда), однако программа пытается показать их как одну плавную поверхность.
Именно поэтому в 3D-редакторах обычно есть параметр углов сглаживания: если у нас есть два связанных полигона под углом, превышающем угол сглаживания, то их переход будет плавным, а соединение полигонов под углом меньше угла сглаживания будет жёстким. Благодаря этому крутые углы между поверхностями будут отображаться как разные поверхности, как это и бывает в реальном мире.
Итак, мы использовали нормали для контроля над переходами между гранями модели, но можно пойти ещё дальше.
Так как мы меняем способ отражения света от объекта, можно также сделать так, чтобы очень простой объект отражал свет, как сложный. Это называется картой нормалей. Мы используем текстуру для изменения направления света, отражающегося от 3D-объекта, заставляя его выглядеть сложнее, чем он есть на самом деле.
Примером из реального мира могут служить голограммы, которые раньше вручали в подарок при покупке картофельных чипсов (по крайней мере, у нас, в Испании). Они совершенно плоские, но отражают свет так, как бы это делал 3D-объект, благодаря чему становятся сложнее, чем на самом деле. В мире 3D-графики это работает даже лучше, но всё равно имеет свои ограничения (поскольку поверхность остаётся плоской).
Хоть мы и применяем нормали полигонов для реализации какой-то чёрной магии, на самом деле мы не контролируем сглаживание поверхности модели при помощи нормалей полигонов. Мы используем нормали вершин для контроля сглаживания нормалей. По сути, идея та же, но немного более сложная.
С каждой вершиной может быть связано одна или несколько нормалей. Если она имеет одну нормаль, то можно назвать её усреднённой нормалью вершины, а если несколько — то разделённой нормалью вершины.
Давайте возьмём два полигона, соединённых ребром. Если переход между двумя гранями плавный (если мы указали его как плавный в Maya/Modo, или обе имеют одинаковую smoothing group в Max/Blender), то каждая вершина имеет одну нормаль, которая является средней нормалей полигонов (поэтому она и называется усреднённой нормалью вершины). Важное примечание: до недавнего времени каждый 3D-редактор использовал собственный способ вычисления усреднённых нормалей вершин, то есть карты нормалей, вычисленные в одной программе, в другой могли выглядеть совершенно иначе. Подробнее об этом я расскажу во второй части туториала.
Если переход жёсткий (hard edge или разные smoothing groups), то каждая вершина имеет несколько нормалей: по одной для каждой соединённой вершины, выровненной по их нормалям. При этом между нормалями образуется пробел, который выглядит как две разные поверхности. Именно это называется разделённой нормалью вершины.
Как вы могли догадаться, контроль нормалей вершин очень важен, если мы хотим контролировать карты нормалей. К счастью, нам не обязательно изменять нормали напрямую или даже видеть их, но понимание того, как это работает, поможет вам понять, почему мы выполняем работу именно так и больше разбираться в проблемах, с которыми мы можем встретиться.
При запекании карты нормалей мы по сути говорим программе изменить направление, которому следуют нормали lowpoly-модели, так, чтобы они соответствовали направлению в highpoly-модели; поэтому lowpoly-модель будет отражать свет так же, как highpoly. Вся эта информация хранится в текстуре под названием «карта нормалей». Давайте рассмотрим пример.
Допустим, у нас есть вот такая низкополигональная модель (lowpoly). Плоская поверхность с четырьмя вершинами и настроенными UV, которые программа запекания будет использовать для создания карты нормалей.
И она должна получить информацию о нормалях от этой высокополигональной (highpoly) модели, нормали которой сложнее.
Помните, что мы переносим только информацию о нормалях, то есть UV, материал, топология, преобразования и т.п. к делу не относятся. Проверенное правило: если highpoly-модель выглядит хорошо, то её нормали тоже хороши и вполне должны подходить для запекания.
Программа запекания берёт lowpoly-модель и испускает лучи, следуя по направлениям нормалей lowpoly (именно поэтому нам нужно контролировать нормали lowpoly). Эти лучи имеют ограниченную длину чтобы не получать информацию нормалей от далёких граней (обычно это расстояние называется bake distance или cage distance). Когда эти лучи сталкиваются с highpoly, программа запекания вычисляет, как отразить эти лучи, чтобы они следовали по направлению нормалей highpoly, и сохраняет эту информацию в карту нормалей.
Вот результат запекания для нашего примера:
У нас есть текстура, которую движок использует для изменения нормалей lowpoly, чтобы свет отражался от этой lowpoly-модели так же, как он отражался бы от highpoly-версии. Не забывайте, что это только текстура, которая не влияет на силуэт lowpoly-модели (невозможно изменить способ отражения света от модели, если свет не падает на эту модель).
Хотя понятно, что можно «считать» внешний вид highpoly по внешнему виду карты нормалей, очевидно, что карты нормалей — это не обычные текстуры, потому что они хранят информацию не о цвете, а о нормалях. Также это значит, что карты нормалей нельзя рассматривать как обычные текстуры; к тому же, как мы увидим, они обладают особыми параметрами сжатия и гамма-коррекции.
Можно воспринимать карту нормалей как набор из трёх текстур в оттенках серого, хранящийся в одном изображении:
Первое изображение сообщает движку, как эта модель должна отражать свет, падающий справа; оно хранится в красном канале текстуры карты нормалей.
Второе изображение сообщает движку, как модель должна отражать свет, падающий снизу*; оно хранится в зелёном канале текстуры карты нормалей.
*В некоторых программах свет падает не снизу, а сверху, то есть могут быть «левосторонние» и «правосторонние» карты нормалей. Как мы увидим позже, это может вызывать некоторые проблемы.
Третье изображение сообщает движку, как модель должна отражать свет, падающий спереди; оно хранится в синем канале текстуры карты нормалей. Так как большинство объектов при освещении спереди выглядят белыми, карты нормалей обычно кажутся синеватыми.
Когда мы комбинируем все три изображения в одно, то получаем карту нормалей. Помните, что это объяснение не полностью корректно, но надеюсь, что оно позволит вам понять информацию, хранящуюся внутри карты нормалей, и лучше разобраться, что она делает.
Нормали — это векторы, которые используются для определения того, как свет отражается от поверхности. Их можно использовать для контроля над переходом между гранями (усреднением нормалей соединённых вершин для создания плавного перехода или разделением их для создания жёсткого перехода), но также их направление можно изменять, чтобы lowpoly-модель отражала свет так же, как более сложная модель.
Эта информация хранится в трёх отдельных каналах изображения, и 3D-редактор считывает её, чтобы понять, в каком направлении должна смотреть поверхность модели.
В следующей статье цикла мы поговорим о том, как можно запекать эти детали из highpoly-модели в lowpoly.
Learn OpenGL. Урок 5.5 – Normal Mapping
Normal Mapping
Например, возьмите кирпичную кладку. Поверхность её весьма груба и, очевидно, представлена далеко не плоскостью: на ней есть углубления с цементом и множество мелких деталей типа отверстий и трещин. Если сцену с имитацией кирпичной кладки проанализировать в условиях наличия освещения, то иллюзия рельефности поверхности очень легко разрушается. Ниже представлен пример такой сцены, содержащей плоскость с нанесенной текстурой кирпичной кладки и один точечный источник света:
Как видно, освещение совершенно не учитывает предполагаемые для этой поверхности детали рельефа: отсутствуют и все мелкие трещинки, и углубления с цементом неотличимы от остальной поверхности. Можно было бы использовать карту зеркального блеска дабы ограничить освещенность определенных деталей, которые находятся в углублениях поверхности. Но это больше похоже на грязный хак, чем на рабочее решение. Что нам нужно, так это способ обеспечить уравнения освещения данными о микрорельефе поверхности.
В контексте известных нам уравнений освещения подумайте вот над каким вопросом: при каких условиях поверхность будет освещена как идеально плоская? Ответ связан с нормалью к поверхности. С точки зрения алгоритма освещения информация о форме поверхности передается только через вектор нормали. Поскольку у представленной выше поверхности вектор нормали постоянен всюду, то и освещение выходит равномерным, соответствующим плоскости. А что если передавать алгоритму освещения не единственную нормаль, постоянную для всех фрагментов, принадлежащих объекту, а нормаль уникальную для каждого фрагмента? Таким образом вектор нормали будет слегка меняться на основе рельефа поверхности, что создаст более убедительную иллюзию сложности поверхности:
За счет использования пофрагментно отличающихся нормалей алгоритм освещения будет считать поверхность состоящей из множества микроскопических плоскостей, перпендикулярных своему вектору нормали. В итоге это ощутимо добавит объекту фактурности. Техника применения нормалей уникальных для фрагмента, а не всей поверхности – это и есть Normal Mapping или Bump Mapping. В применении к уже знакомой сцене:
Виден впечатляющий рост визуальной сложности за счет весьма скромных затрат производительности. Поскольку мы все изменения в модели освещения заключаются лишь в подаче уникальной нормали в каждом фрагменте, то никакие формулы вычислений не меняются. Только лишь на вход вместо интерполированной нормали к поверхности поступает нормаль для текущего фрагмента. Все те же уравнения освещения проделывают остальную работу по созданию иллюзии рельефа.
Normal Mapping
Итак, получается, нам необходимо обеспечить алгоритм освещения нормалями, уникальными для каждого фрагмента. Воспользуемся уже знакомым по текстурам диффузного и зеркального отражения методом и используем обычную 2D текстуру для хранения данных о нормали в каждой точке поверхности. Не удивляйтесь, текстуры отлично подходят и для хранения векторов нормалей. Далее нам останется только осуществить выборку из текстуры, восстановить вектор нормали и провести расчеты освещения.
На первый взгляд может быть не очень ясно, как сохранить векторные данные в обычной текстуре, которая типично используется для хранения информации о цвете. Но задумайтесь на секунду: цветовая триада RGB по сути и есть трехмерный вектор. Похожим образом можно сохранить компоненты вектора нормали XYZ в соответствующих компонентах цвета. Величины компонент вектора нормали лежат в интервале [-1, 1] и потому требуют дополнительного преобразования в интервал [0, 1]:
Такое приведение вектора нормали в пространство цветовых компонент RGB позволит нам сохранить в текстуре вектора нормалей, полученные на основе реального рельефа моделируемого объекта и уникальные для каждого фрагмента. Пример такой текстуры – карты нормалей – для все той же кирпичной кладки:
Интересно отметить синий оттенок этой карты нормалей (практически все карты нормалей имеют схожий оттенок). Так получается, поскольку все нормали сориентированы приблизительно вдоль оси oZ, которая представляется координатной тройкой (0, 0, 1), т.е. в виде цветовой триады – чисто синий цвет. Небольшие изменения оттенка являются следствием отклонения нормалей от положительной полуоси oZ на некоторых участках, что соответствует неровностям рельефа. Так, можно заметить, что на верхних кромках каждого кирпича текстура приобретает зеленый оттенок. И это логично: на верхних гранях кирпича нормали должны бы быть сориентированы больше в сторону оси oY (0, 1, 0), что соответствует зеленому цвету.
Для тестовой сцены возьмем плоскость, сориентированную в сторону положительной полуоси oZ и используем для нее следующие диффузную карту и карту нормалей.
Как видно, они не совместимы. И немного покумекав можно понять, что DirectX полагает касательное пространство леворуким, а OpenGL праворуким. Подсунув иксовую карту нормалей нашему приложению без изменений получим некорректное освещение, причем не всегда сразу видно, что оно некорректное. Самое заметное — то, что выпуклости в формате OpenGL становятся углублениями для DirectX и наоборот.
Что касается адресации: загружая данные из файла текстур в память мы предполагаем, что первый тексель — это левый верхний тексель изображения. Для представления текстурных данных в памяти приложения это, вобщем случае, верно. Но OpenGL использует другую систему текстурных координат: для нее первый тексель — это левый нижний. Для корректного текстурирования изображения, обычно, переворачиваются по оси Y еще в коде того или иного загрузчика файлов изображений. Для используемой в уроках либы Stb_image нужно добавить установку флажка
Что самое забавное, то корректно в плане освещения отображаются два варианта: карта нормалей в нотации OpenGL с включенным отражением по Y или карта нормалей в нотации DirectX с выключенным отражением по Y. Освещение в обоих случаях работает корректно, разница останется только в инверсии текстур по оси Y.
Итак, загрузим обе текстуры, привяжем к текстурным блокам и отрендерим подготовленную плоскость, с учетом следующих модификаций кода фрагментного шейдера:
Здесь мы применяем обратное преобразование из пространства значений RGB в полноценный вектор нормали и далее просто используем его в хорошо знакомой модели освещения Блинна-Фонга.
Теперь, если медленно менять положение источника света в сцене, то можно ощутить иллюзию рельефности поверхности, обеспечиваемую картой нормалей:
Но остается одна проблема, которая радикально сужает круг возможного применения карт нормалей. Как уже отмечено синий оттенок карты нормалей намекал на то, что все вектора в текстуре ориентированы в среднем вдоль положительной оси oZ. В нашей сцене это не создало проблем, ведь нормаль к поверхности плоскости также была сонаправлена с oZ. Однако, что случится, если мы изменим положение плоскости в сцене так, что нормаль к ней будет сонаправлена с положительной полуосью oY?
Освещение оказалось полностью неверным! И причина проста: выборки нормалей из карты все также возвращают вектора, ориентированные вдоль положительной полуоси oZ, хотя в данном случае им следовало бы быть ориентированными в направлении положительной полуоси oY нормали поверхности. В данный же момент расчет освещения идет так, будто нормали к поверхности расположены так, будто плоскость все еще ориентирована в сторону положительной полуоси oZ, что дает неверный результат. Рисунок ниже более наглядно показывает ориентацию считанных из карты нормалей относительно поверхности:
Видно, что нормали в целом выровнены вдоль положительной полуоси oZ, хотя должны бы быть выровнены вдоль нормали к поверхности, которая направлена вдоль положительной полуоси oY.
Возможным решением могло бы быть задание отдельной карты нормалей для каждой ориентации рассматриваемой поверхности. Для куба понадобилось бы шесть карт нормалей, а для более сложных моделей число возможных ориентаций может быть слишком высоко и для реализации не годится.
Есть другой, математически более сложный подход, предлагающий вести расчеты освещения в другой системе координат: такой, что вектора нормали в ней всегда примерно совпадают с положительной полуосью oZ. Другие вектора, требующиеся для расчетов освещения тогда преобразуются в эту систему координат. Такой метод дает возможность использовать одну карту нормалей для любой ориентации объекта. А эта специфичная система координат называется касательным пространством или tangent space.
Касательное пространство
Стоит отметить, что вектор нормали в карте нормалей выражен непосредственно в касательном пространстве, т.е. в такой системе координат, что нормаль всегда направлена примерно в направлении положительной полуоси oZ. Касательное пространство задано как система координат, локальная для плоскости треугольника и каждый вектор нормали задается в рамках этой системы координат. Можно представить эту систему и как локальную систему координат для карты нормалей: все вектора в ней заданы направленными в сторону положительной полуоси oZ вне зависимости от конечной ориентации поверхности. Используя специально подготовленные матрицы трансформации можно преобразовать вектора нормалей из этой локальной касательной системы координат в мировые или видовые координаты, ориентируя их в соответствии с окончательным положением поверхностей, подвергаемых текстурированию.
Рассмотрим предыдущий пример с некорректным применением normal mapping’а, где плоскость была сориентирована вдоль положительной полуоси oY. Так как карта нормалей задана в касательном пространстве, то один из вариантов корректировки – это расчет матрицы перехода нормалей из касательного пространства в такое, что они бы стали ориентированы по нормали к поверхности. Это привело бы к тому, что нормали стали бы выровнены вдоль положительной полуоси oY. Замечательным свойством касательного пространства является тот факт, что расчитав такую матрицу мы сможем переориентировать нормали к любой поверхности и её ориентации.
Такая матрица сокращенно обозначается как TBN, что есть сокращение от названия тройки векторов Tangent, Bitangent и Normal. Эти три вектора нам необходимо найти, дабы сформировать эту матрицу смены базиса. Такая матрица осуществляет переход вектора из касательного пространства в какое-либо другое и для её формирования необходимы три взаимоперпендикулярных вектора, ориентация которых соответствует ориентации плоскости карты нормалей. Это вектор направления вверх, вправо и вперед, набор знакомый нам по уроку о виртуальной камере.
С вектором верх все ясно сразу – это наш вектор нормали. Вектор вправо и вперед называются касательная (tangent) и бикасательная (bitangent) соответственно. Следующий рисунок дает представление об их взаимном расположении на плоскости:
Расчет касательной и бикасательной не настолько очевиден, как расчет вектора нормали. На рисунке можно заметить, что направление касательной и бикасательной карты нормали выровнены с осями, задающими текстурные координаты поверхности. Данный факт и положен в основу расчета этих двух векторов который потребует некоторой сноровки с математикой. Посмотрите на рисунок:
Преобразуя в поэлементную запись получим:
вычисляется как вектор разности двух векторов, а
и
как разности текстурных координат. Остается найти две неизвестных в двух уравнениях: касательную
и бикасательную
. Если еще припоминаете уроки алгебры, то знаете, что такие условия позволяют решить систему и для
и для
.
Последняя приведенная форма уравнений позволяет нам переписать её в форме матричного умножения:
Попробуйте мысленно провести матричное умножение, чтобы убедиться в верности записи. Запись системы в матричной форме позволяет гораздо легче понять подход к нахождению и
. Умножим обе части уравнения на величину обратную
:
Получаем решение относительно и
, которое, однако, требует расчета обратной матрицы изменений текстурных координат. Не будем углубляться в подробности вычисления обратных матриц – выражение для обратной матрицы выглядит как произведение числа, обратного к детерминанту исходной матрицы, и присоединенной матрицы:
Данное выражение и есть формула для расчета вектора касательной и бикасательной
на основе координат граней треугольника и соответствующих текстурных координат.
Не переживайте, если суть приведенных математических выкладок ускользает от вас. Если вы понимаете, что касательную и бикасательную мы получаем на основе координат вершин треугольника и их текстурных координат (поскольку текстурные координаты также принадлежат касательному пространству) – это уже половина дела.
Расчет тангентов и битангентов
В примере этого урока мы взяли простую плоскость, смотрящую в сторону положительной полуоси oZ. Сейчас же попробуем реализовать normal mapping используя касательное пространство, чтобы иметь возможность ориентировать плоскость в примере как нам вздумается, не разрушая эффекта normal mapping’а. Используя вышеописанный расчет мы вручную найдем касательную и бикасательную к рассматриваемой поверхности.
Примем, что плоскость составлена из следующих вершин с текстурными координатами (два треугольника заданы векторами 1, 2, 3 и 1, 3, 4):
Сначала рассчитываем вектора, описывающие грани треугольника, а также дельты текстурных координат:
Имея на руках необходимые исходные данные мы можем приступить к расчету касательной и бикасательной прямо по формулам из предыдущего раздела:
Сперва выносим дробный компонент итогового выражения в отдельную переменную f. Затем для каждого компонента векторов выполняем соответствующую часть матричного умножения и умножаем на f. Сравнив данный код с итоговой формулой расчета можно увидеть, что это буквальное её переложение. Не забудьте в конце провести нормализацию, дабы найденные вектора были единичными.
Поскольку треугольник плоская фигура, то расчет касательной и бикасательной достаточно провести один раз на треугольник – они будут одинаковые для всех вершин. Стоит отметить, что большая часть реализаций работы с моделями (типа загрузчиков или генераторов ландшафтов) используют такую организацию треугольников, где они делят вершины с другими треугольниками. В таких случаях разработчики, обычно, прибегают к усреднению параметров в общих вершинах, таких как вектора нормали, касательно и бикасательной, чтобы получить более сглаженный результат. Треугольники, составляющие нашу плоскость тоже делят несколько вершин, но поскольку они оба лежат в одной плоскости, то усреднение не потребуется. И все же полезно помнить о наличии такого подхода в реальных приложениях и задачах.
Получившиеся вектора касательной и бикасательной должны иметь значения (1, 0, 0) и (0, 1, 0) соответственно. Что вкупе с вектором нормали (0, 0, 1) формируют ортогональную матрицу TBN. Если визуализировать полученный базис вместе с плоскостью, то получится следующее изображение:
Теперь, имея рассчитанные вектора можно приступать к полноценной реализации normal mapping’а.
Normal mapping в касательном пространстве
Для начала необходимо сформировать матрицу TBN в шейдерах. Для этой цели мы передадим заранее подготовленные вектора касательной и бикасательной в вершинный шейдер через вершинные атрибуты:
В самом коде вершинного шейдера сформируем непосредственно матрицу:
В приведенном коде сперва преобразуем все вектора базиса касательного пространства в систему координат, в которой нам удобно работать – в данном случае это мировая система координат и мы умножаем вектора на модельную матрицу model. Далее мы создаем саму матрицу TBN просто передавая конструктору типа mat3 все три соответствующих вектора. Обратите внимание на то, что для полной корректности порядка вычислений необходимо проводить умножение векторов не на модельную матрицу, а на матрицу нормалей, поскольку нас интересует лишь ориентация векторов, но не их смещение или масштабирование
Строго говоря, передавать вектор бикасательной в шейдер вовсе не обязательно.
Поскольку тройка векторов TBN взаимно перпендикулярна, то бикасательную можно банально найти в шейдере через векторное умножение:
Итак, получена матрица TBN, как нам ее использовать? По сути есть два подхода к её применению в normal mapping’е:
Передача матрицы TBN делается простейшим образом:
В коде фрагментного шейдера, соответственно, задаем входную переменную типа mat3:
Имея матрицу на руках можно уточнить код получения нормали выражением перевода из касательного в мировое пространство:
Поскольку полученная нормаль теперь задана в мировом пространстве нет необходимости менять что-либо еще в коде шейдера. Расчеты освещения и так предполагают вектор нормали заданным в мировых координатах.
Давайте также рассмотрим и второй подход. Он потребует получения матрицы обратной TBN, а также переноса всех векторов, участвующих в расчете освещения, из мировой системы координат в ту, что соответствует векторам нормали, полученным из текстуры – касательную. В данном случае формирование матрицы TBN остается неизменным, но перед передачей во фрагментный шейдер мы должны получить обратную матрицу:
Обратите внимание на то, что применена функция transpose()вместо inverse(). Такая подстановка справедлива, поскольку для ортогональных матриц (где все оси представлены единичными взаимно перпендикулярными векторами) получение обратной матрицы дает результат идентичный транспонированию. И это весьма кстати, поскольку, в общем случае, вычисление обратной матрицы куда более вычислительно затратное дело по сравнению с транспонированием.
В коде фрагментного шейдера мы не будем преобразовывать вектор нормали, вместо этого преобразуем из мировой системы координат в касательную прочие важные вектора, а именно lightDir и viewDir. Это решение также приводит все элементы вычислений в единую систему координат, на этот раз – касательную.
Второй подход кажется более трудоемким и требует больше матричных умножений во фрагментном шейдере (что сильно влияет на производительность). Почему мы вообще взялись его разбирать?
Дело в том, что перевод векторов из мировых координат в касательные предоставляет дополнительное преимущество: фактически мы можем вынести весь код преобразований из фрагментного в вершинный шейдер! Такой подход является рабочим поскольку lightPos и viewPos не изменяются от фрагмента к фрагменту, а значение fs_in.FragPos мы также можем перевести в касательное пространство в вершинном шейдере, интерполированное значение на входе во фрагментный шейдер будет вполне корректным. Таким образом, для второго подхода нет никакой надобности переводить все эти вектора в касательное пространство в коде фрагментного шейдера, в то время как первый этого требует – ведь нормаль является уникальной для каждого фрагмента.
В итоге мы уходим от передачи матрицы обратной к TBN в фрагментный шейдер и вместо этого передаем ему вектора положения вершины, источника света и наблюдателя в касательном пространстве. Так мы избавимся от затратных матричных умножений во фрагментном шейдере, что будет значительной оптимизацией, ведь вершинный шейдер исполняется гораздо реже. Именно это преимущество и выдвигает второй подход в разряд предпочтительного в большинстве случаев использования.
Во фрагментном шейдере мы переходим на использование новых входных переменных в расчетах освещения в касательном пространстве. Поскольку нормали у нас по условию заданы в этом пространстве, то все расчеты остаются корректными.
Теперь, когда все расчеты normal mapping’а ведутся в касательном пространстве, мы можем изменять ориентацию тестовой поверхности в приложении как хотим и освещение останется корректным:
Действительно, внешне все выглядит как надо:
Сложные объекты
Итак, мы разобрались в том, как осуществить normal mapping в касательном пространстве и как для этого самостоятельно вычислить вектора касательной и бикасательной. К счастью, такой ручной расчет не то чтобы часто возникающая задача: по большей части этот код реализуется разработчиками где-то в недрах загрузчика моделей. В нашем случае, это верно для использующегося загрузчика Assimp.
Assimp предоставляет весьма полезный флаг опций при загрузке моделей: aiProcess_CalcTangentSpace. При его передаче функции ReadFile()библиотека сама займется расчетом сглаженных касательных и бикасательных для каждой из загруженных вершин – процесс похожий на рассмотренный здесь.
После этого можно прямо получить доступ к рассчитанным касательным:
Также необходимо будет обновить код загрузки, чтобы он учитывал получение карт нормалей для текстурированных моделей. Формат Wavefront Object (.obj) экспортирует карты нормалей таким образом, что флаг Assimp aiTextureType_NORMAL не обеспечивает корректной загрузки этих карт, в то время как с флагом aiTextureType_HEIGHT все работает корректно. Поэтому лично я, обычно, загружаю карты нормалей следующим способом:
Безусловно, для других форматов описания модели и типов файлов этот подход может не подходить. Также отмечу, что выставление флага aiProcess_CalcTangentSpace не всегда срабатывает. Нам известно, что вычисление касательных основывается на текстурных координатах, однако, зачастую авторы моделей применяют различные ухищрения к текстурным координатам, что ломает расчет касательных. Так, нередко применяется зеркальное отражение текстурных координат для симметрично оттекстурированных моделей. Если не учитывать факт зеркальности, то расчет касательных будет неверен. Assimp не делает такого учета. Знакомая нам модель нанокостюма вот не подходит для демонстрации, поскольку также использует зеркалирование.
Но с корректно оттекстурированной моделью, использующей карты нормалей и зеркального отражения, тестовое приложение дает весьма неплохой результат:
Как видно, применение normal mapping’а дает ощутимый прирост детальности и при этом дешев в плане затрат производительности.
Не стоит забывать, что применение normal mapping’а может позволить повысить производительность для конкретной сцены. Без его использования достижение детальности модели возможно только через увеличение плотности полигональной сетки, меша. Но данная техника позволяет добиться визуально того же уровня детализации для низкополигональных мешей. Ниже можно увидеть сравнение этих двух подходов:
Уровень детализации на высокополигональной модели и на низкополигональной с применением normal mapping’а практически неотличим. Так что эта техника – отличный метод заместить высокополигональные модели в сцене упрощенными практически без потери в визуальном качестве.
Последнее замечание
Есть еще одна техническая деталь, касающаяся normal mapping’а, которая немного улучшает качество практически без дополнительных расходов.
В тех случаях, когда касательные рассчитываются для больших и сложных мешей, имеющих значительное количество вершин, принадлежащих нескольким треугольникам, касательные вектора, обычно, усредняются, чтобы получить гладкий и визуально приятный результат normal mapping’а. Однако, это создает проблему: после усреднения тройка векторов TBN может потерять взаимную перпендикулярность, что также значит потерю ортогональности для матрицы TBN. В общем случае результат normal mapping’а, полученный на основе неортогональной матрицы, лишь слегка некорректен, но все же мы можем улучшить его.
Для этого достаточно применить простой математический метод: процесс Грамма-Шмидта или ре-ортогонализация нашей тройки векторов TBN. В коде вершинного шейдера:
Эта, пусть и небольшая, поправка улучшает качество normal mapping’а в обмен на мизерные накладные расходы. Если вам интересны детали этой процедуры, то можете посмотреть последнюю часть видеоролика Normal Mapping Mathematics, ссылка на который дана ниже.