Дошли руки написать очередное дополнение к моему краткому курсу компьютерной графики. Итак, тема для очередного разговора — использование карт нормалей. В чём основное отличие использования карт нормалей от затенения Фонга? Основная разница в плотности задания информации. Для затенения Фонга мы использовали нормальные вектора, заданные к каждой вершине нашей полигональной сетки, интерполируя нормали внутри треугольников. Использование же карт нормалей позволяет задавать нормали для каждой точки нашей поверхности, а не лишь изредка, что просто драматическим образом влияет на детализацию изображений.
В принципе, в лекции про шейдеры мы уже использовали карту нормалей, но только заданную в глобальной системе координат. Сейчас же разговор пойдёт про касательное пространство. Итак, вот две текстуры, левая задана в глобальном пространстве (RGB напрямую превращается в вектор XYZ), а правая — в касательном.
Чтобы использовать нормаль, заданную в касательном пространстве, для рисуемого пикселя мы вычисляем касательный репер (репер Дарбу). В этом репере один вектор (z) ортогонален поверхности, а два других задают касательную плоскость к поверхности. Затем читаем нормальный вектор из текстуры, и преобразуем его координаты из только что вычисленного репера в глобальный. Поскольку карта нормалей чаще всего задаёт лишь небольшое возмущение нормали, то доминирующий цвет у текстуры синий.
Казалось бы, зачем такие сложности? Почему не пользоваться простым глобальным репером как раньше? Представьте себе, что мы хотим анимировать нашу модель. Например, я взял нашего старого знакомого негра и открыл ему рот. Очевидно, что у модифицированной поверхности должны быть и другие нормали!
Вот слева модель, в которой рот открыт, а карта нормалей (глобальная) изменена не была. Посмотрите на слизистую нижней губы. Свет бьёт прямо в лицо, у модели с закрытым ртом слизистая, разумеется, не была освещена никак. Рот открылся, а она по-прежнему не освещена… Правая картинка посчитана с использованием карты нормалей, заданной в касательном пространстве.
Итак, если у нас есть анимированная модель, то для задания карты нормалей в глобальном пространстве нам нужна одна текстура на каждый кадр анимации. А поскольку касательное пространство натурально следует за поверхностью, то такой текстуры нам достаточно одной!
Вот второй пример:
Это текстуры для модели Диабло. Обратите внимание, что на текстуре видна только одна рука. И только одна половина хвоста. Художник использовал одну и ту же текстуру для левой и правой руки, и одну и ту же текстуру для левой и правой части хвоста. (К слову сказать, это то, что помешало нам посчитать ambient occlusion.) А это означает, что в глобальной системе координат я могу задать нормальные вектора либо для левой руки, либо для правой. Но никак не для двух разом!
Итак, заканчиваем с мотивацией и переходим непосредственно к вычислениям.
Отправная точка, затенение Фонга
Итак, давайте посмотрим на отправную точку. Шейдер очень простой, это затенение Фонга.
struct Shader : public IShader {
mat<2,3,float> varying_uv; // triangle uv coordinates, written by the vertex shader, read by the fragment shader
mat<3,3,float> varying_nrm; // normal per vertex to be interpolated by FS
virtual Vec4f vertex(int iface, int nthvert) {
varying_uv.set_col(nthvert, model->uv(iface, nthvert));
varying_nrm.set_col(nthvert, proj<3>((Projection*ModelView).invert_transpose()*embed<4>(model->normal(iface, nthvert), 0.f)));
Vec4f gl_Vertex = Projection*ModelView*embed<4>(model->vert(iface, nthvert));
varying_tri.set_col(nthvert, gl_Vertex);
return gl_Vertex;
}
virtual bool fragment(Vec3f bar, TGAColor &color) {
Vec3f bn = (varying_nrm*bar).normalize();
Vec2f uv = varying_uv*bar;
float diff = std::max(0.f, bn*light_dir);
color = model->diffuse(uv)*diff;
return false;
}
};
Вот результат работы шейдера:
Для простоты обучения и отладки я уберу текстуру кожи и применю простейшую регулярную сетку с горизонтальными красными и вертикальными синими линиями:
Давайте посмотрим, как работает наш шейдер Фонга на примере этой картинки:
Итак, для каждой вершины треугольника у нас даны её координаты p, её текстурные координаты uv и нормальные вектора к вершинам n. Для отрисовки каждого пикселя растеризатор нам даёт барицентрические координаты пикселя (альфа, бета, гамма). Это означает, что текущий пиксель имеет пространственные координаты p = альфа p0 + бета p1 + гамма p2. Мы интерполируем текстурные координаты ровно так же, затем интерполируем и вектор нормали:
Обратите внимание, что красные и синии линии — это изолинии u и v, соответственно. Итак, для каждой точки нашей поверхности у нас задаётся (скользящий) репер Дарбу, у которого ось x параллельна красным линиям, ось y параллельна синим, а z ортогональная поверхности. Именно в этом репере и задаются нормальные вектора.
Вычисляем линейную функцию по трём её точкам
Итак, наша задача — для каждого рисуемого пикселя посчитать тройку векторов, задающую репер Дарбу. Давайте для начала отвлечёмся и представим, что в нашем пространстве задана линейная функция f, которая каждой точке (x,y,z) сопоставляет вещественное число f(x,y,z) = Ax + By + Cz + D. Единственное, что мы не знаем чисел (A,B,C,D), но знаем значение функции в вершинах некоторого треугольника (p0, p1, p2):
Можно представлять себе, что f — это просто высота некоторой наклонной плоскости. Мы фиксируем три разные точки на плоскости и знаем значения высот в этих точках. Красные линии на треугольнике показывают изолинии высоты: изолиния для высоты f0, для высоты f0+1 метр, f0+2 метра и т.д. Для линейной функции все эти изолинии, очевидно, являются параллельными прямыми.
Что нам интересно это даже не столько направление изолиний, сколько направление, им ортогональное. Если мы передвигаемся вдоль какой-то изолинии, то высота не меняется (спасибо, капитан), если мы отклоняем направление чуть-чуть от изолинии, то высота начинает меняться, а самое большое изменение на единицу высоты будет тогда, когда мы будем передвигаться в направлении, ортогональном изолиниям.
Вспоминаем, что направление наискорейшего подъёма для какой-то функции есть не что иное, как её градиент. Для линейной функции f(x,y,z) = Ax + By + Cz + D её градиент — это постоянный вектор (A, B, C). Логично, что он постоянный, так как любая точка плоскости наклонена одинаково. Напоминаю, что мы не знаем чисел (A,B,C). Мы знаем только значение нашей функции в трёх разных точках. Можем ли мы восстановить A, B и С? Конечно.
Итак, у нас есть три точки p0, p1, p2 и три значения функции f0, f1, f2. Нам интересно найти вектор (A,B,C), дающий направление наискорейшего роста функции f. Давайте для удобства будем рассматривать функцию g(p), которая задаётся как g(p) = f(p) — f(p0):
Очевидно, что мы просто переместили нашу наклонную плоскость, не изменив её наклона, поэтому направление наискорейшего роста для функции g будет совпадат с направлением наискорейшего роста функции f.
Давайте перепишем определение функции g:
Обратите внимание, что верхний индекс p^x — это координата x точки p, а не степень. То есть, функция g — это всего-навсего скалярное произведение вектора, соединяющего текущую точку p с точкой p0 и вектора (A,B,C). Но ведь мы по-прежнему не знаем (A,B,C)! Не страшно, сейчас их найдём.
Итак, что нам известно? Нам известно, что если от точки p0 мы пойдём в p2, то функция g будет равняться f2-f0. Иными словами, скалярное произведение между векторами p2-p0 и ABC равняется f2-f0. То же самое для dot (p1-p0,ABC)=f1-f0. То есть, мы ищем вектор (ABC), который одновременно ортогонален нормали к треугольнику и имеет эти два ограничения на скалярные произведения:
Запишем то же самое в матричной форме:
То есть, мы получили матричное уравнение Ax = b, которое легко решается:
Обратите внимание, что я использовал литеру А в двух смыслах, значение должно быть ясно из контекста. То есть, наша 3x3 матрица A, помноженная на неизвестный вектор x=(A,B,C), даёт вектор b = (f1-f0, f2-f0, 0). Неизвестный вектор находится умножением матрицы, обратной к A, на вектор b.
Обратите внимание, что в матрице A нет ничего, что зависит от функции f! Там содержится только информация о геометрии треугольника.
Вычисляем репер Дарбу и применяем карту (возмущения) нормалей
Итого, репер Дарбу — это тройка векторов (i,j,n), где n — это вектор нормали, а i и j могут быть подсчитаны следующим образом:
Вот финальный код, использующий карты нормалей, заданные в касательном пространстве, а тут можно найти изменения в коде по сравнению с тонировкой Фонга.
Там всё довольно прямолинейно, я вычисляю матрицу A:
mat<3,3,float> A;
A[0] = ndc_tri.col(1) - ndc_tri.col(0);
A[1] = ndc_tri.col(2) - ndc_tri.col(0);
A[2] = bn;
Затем вычисляю вектора репера Дарбу:
mat<3,3,float> AI = A.invert();
Vec3f i = AI * Vec3f(varying_uv[0][1] - varying_uv[0][0], varying_uv[0][2] - varying_uv[0][0], 0);
Vec3f j = AI * Vec3f(varying_uv[1][1] - varying_uv[1][0], varying_uv[1][2] - varying_uv[1][0], 0);
Ну а однажды их вычислив, читаю нормаль из текстуры, и делаю простейшее преобразование координат из репера Дарбу в глобальный репер.
Если что, то преобразования координат я уже описывал.
Вот финальный рендер, сравните детализацию с тонировкой Фонга:
Совет по отладке
Самое время вспомнить, как отрисовываются линии. Наложите на модель регулярную красно-синюю сетку и для всех вершин отрисуйте полученные вектора (i,j), они должны хорошо совпасть с направлением красно-синих линий текстуры.
Happy coding!
Автор: haqreu