Краткий курс компьютерной графики: задание карт нормалей в касательном пространстве

  • Tutorial
Дошли руки написать очередное дополнение к моему краткому курсу компьютерной графики. Итак, тема для очередного разговора — использование карт нормалей. В чём основное отличие использования карт нормалей от затенения Фонга? Основная разница в плотности задания информации. Для затенения Фонга мы использовали нормальные вектора, заданные к каждой вершине нашей полигональной сетки, интерполируя нормали внутри треугольников. Использование же карт нормалей позволяет задавать нормали для каждой точки нашей поверхности, а не лишь изредка, что просто драматическим образом влияет на детализацию изображений.

В принципе, в лекции про шейдеры мы уже использовали карту нормалей, но только заданную в глобальной системе координат. Сейчас же разговор пойдёт про касательное пространство. Итак, вот две текстуры, левая задана в глобальном пространстве (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!

Насколько вы были внимательны?
А заметили ли вы, что вообще у (плоского) треугольника вектор нормали постоянен, а я использовал интерполированную нормаль в последней строчке матрицы A? Почему я так сделал?

Поддержать автора
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 14

    +3
    Для меня эти дела ещё сложноваты, но я рискну.

    Мой ответ
    Чтобы не были видны грани и рёбра, ведь мы имитируем многогранником криволинейный объект.

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

      +1
      Спасибо, правильно.
        –5
        Для тех, кто любит игры и не хочет решать задачу
        P.S. The Witness — хорошая игра, но мы пытаемся делать что-то близкое к реальности.

          +2
          А вот это было лишним, как мне кажется...
      +1
      Для отрисовки каждого пикселя растеризатор нам даёт барицентрические координаты пикселя (альфа, бета, гамма). Это означает, что текущий пиксель имеет пространственные координаты p = альфа p0 + бета p1 + гамма p2. Мы интерполируем текстурные координаты ровно так же, затем интерполируем и вектор нормали:
      Если я правильно понимаю, то линейно интерполировать текстурные координаты и нормали после проецирования (а раз речь идет о растеризаторе, то получается после) можно только в случае ортогональной проекции. Правильно?
      В случае с перспективной проекцией, там будут искажения. Я когда-то очень давно писал свой софтовый рендер и уперся в эту проблему. Несколько дней дебажил, думал что проблема в коде, а оказалась — в математике.
      Забавно то, что на моем Asus A686 (Windows Mobile 5.0) по какой-то причине растеризатор как раз не учитывал перспективные искажения, получались точно такие же забавные артефакты.
        +2
        В целом да, перспектива вносит нелинейные искажения. Их коррекция подробно описана тут.
          0
          Спасибо! Я когда-то давно взял готовый рецепт, но не понял как оно работает, а тут вы все подробно расписали, спасибо!
        +3
        Есть еще один интересный момент, про который забыли сказать — само хранение и представление нормалей.
        Дефакто подразумевается, что они приходят в RGB текстуре, где каждая компонента меняется в -127:127.
        На самом деле это не так. Из-за того, что данные хранятся в нормализованном виде, часть значений не может быть использована.
        Есть другой подход – "Octahedron normal vector encoding" – представление нормали как луча пересекающего октаеэдрон, или пирамиду. В том числе конечный 2д вектор представляется как 2д вектор, и чуть более "линеен".
        Сам про это читал в документах valve, но сейчас не могу найти документ :(
        Но в статье https://knarkowicz.wordpress.com/2014/04/16/octahedron-normal-vector-encoding/ есть пара ссылочек на докумены с тервером…
        PS: Картинка для привлечения внимания:

        0
        Раздел по мультисемплингу будет?
          +2
          Будет, но дату доставки обещать не могу.
          0
          Поправьте меня, если я не прав, но ведь первая normal-map задана скорее в object space, а не в world space. Чтобы трансформировать её в world space, нужно умножить на транспонированную обратную model-матрицу.
          P.S. А репер — устоявщаяся терминология? Википедия говорит Поверхность.
            0
            Да, это так, но такие детали я опускаю за очевидностью. В моём движке model матрица единична.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое