Короткий курс комп'ютерної графіки: завдання карт нормалей в дотичному просторі

Дійшли руки написати чергове доповнення до моєму короткому курсу комп'ютерної графіки. Отже, тема для чергової розмови — використання карт нормалей. У чому основна відмінність використання карт нормалей від затінення Фонга? Основна різниця в щільності завдання інформації. Для затінення Фонга ми використовували нормальні вектори, задані до кожної вершини нашої полігональної сітки, интерполируя нормалі всередині трикутників. Використання карт нормалей дозволяє задавати нормалі для кожної точки поверхні нашої, а не лише зрідка, що просто драматичним чином впливає на деталізацію зображення.

В принципі, лекції про шейдери ми вже використовували карту нормалей, але тільки задану в глобальній системі координат. Зараз розмова піде про дотичний простір. Отже, ось дві текстури, ліва задана в глобальному просторі (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;
}

віртуальний 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? Чому я так зробив?



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.