Короткий курс комп'ютерної графіки: пишемо спрощений OpenGL своїми руками, стаття 5 із 6

Зміст основного курсу


Поліпшення коду


Прийшла пора веселощів, давайте для початку дивитися розмір коду:
  • geometry.cpp+.h — 218 рядків
  • model.cpp+.h — 139 рядків
  • our_gl.cpp+.h — 102 рядка
  • main.cpp — 66 рядків


Разом 525 рядків. Рівно те, що я обіцяв на самому початку курсу. І зауважте, що промальовкою ми займаємося тільки в our_gl і main, а це всього 168 рядків, і ніде ми не викликали сторонніх бібліотек, вся побудова зроблена нами з нуля!
Я нагадую, що мій код потрібен тільки для фінального порівняння з вашим працюючим кодом! По-хорошому, ви всі повинні написати з нуля, якщо прямуєте цього циклу статей. Дуже прошу, робіть самі божевільні шейдери і викладайте в коментарі картинки!!!



Чорні трикутники на рогах — це модель бита злегка, просто мені набридла голова старого негра, а лагодити не сильно хочеться.



Рефакторимо наш код, щоб він був схожий на структуру OpenGL
Отже, наш main.cpp починає злегка розростатися, тому давайте розділимо його на дві частини
  • our_gl.cpp + .h — це частина, яку ми програмувати не можемо, грубо кажучи, бінарний файл бібліотеки.
  • main.cpp — тут ми можемо програмувати що хочемо.


Давайте детальніше, що я виніс у our_gl? Фунції побудови матриць проекції, види і переходу до екранними координатами, а також самі матриці просто глобальними змінними. Ну і функцію-растеризатор трикутника. Всі!

Ось вміст файлу our_gl.h (про призначення IShader трохи пізніше):
#include "tgaimage.h"
#include "geometry.h"

extern Matrix ModelView;
extern Matrix Viewport;
extern Matrix Projection;

void viewport(int x, int y, int w, int h);
void projection(float coeff=0.f); // coeff = -1/c
void lookat(Vec3f eye, Vec3f center, Vec3f up);

struct IShader {
virtual ~IShader() = 0;
virtual Vec3i vertex(int iface, int nthvert) = 0;
віртуальний bool fragment(Vec3f bar, TGAColor &color) = 0;
};

void triangle(Vec3i *pts, IShader &shader, TGAImage &image, TGAImage &zbuffer);


У файлі main.cpp залишилося всього 66 рядків, тому я його даю цілком (вибачте за простирадло, але мені цей файл настільки подобається, що я не буду його ховати під спойлер):
#include < vector>
#include < iostream>

#include "tgaimage.h"
#include "model.h"
#include "geometry.h"
#include "our_gl.h"

Model *model = NULL;
const int width = 800;
const int height = 800;

Vec3f light_dir(1,1,1);
Vec3f eye(1,1,3);
Vec3f center(0,0,0);
Vec3f up(0,1,0);

struct GouraudShader : public IShader {
Vec3f varying_intensity; // written by vertex shader, read by fragment shader

virtual Vec3i vertex(int iface, int nthvert) {
Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); // read the vertex from .obj file
gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert)*light_dir); // get diffuse lighting intensity
return proj<3>(gl_Vertex/gl_Vertex[3]); // project homogenious coordinates to 3d
}

віртуальний bool fragment(Vec3f bar, TGAColor &color) {
float intensity = varying_intensity*bar; // interpolate intensity for the current pixel
color = TGAColor(255, 255, 255)*intensity; // well duh
return false; // no, we do not discard this pixel
}
};

int main(int argc, char** argv) {
if (2==argc) {
model = new Model(argv[1]);
} else {
model = new Model("obj/african_head.obj");
}

lookat(eye, center, up);
viewport(width/8, height/8, width*3/4, height*3/4);
projection(-1.f/(eye-center).norm());
light_dir.normalize();

TGAImage image (width, height, TGAImage::RGB);
TGAImage zbuffer(width, height, TGAImage::GRAYSCALE);

GouraudShader shader;
for (int i=0; i<model->nfaces(); i++) {
Vec3i screen_coords[3];
for (int j=0; j < 3; j++) {
screen_coords[j] = shader.vertex(i, j);
}
triangle(screen_coords, shader, image, zbuffer);
}

image. flip_vertically(); // to place the origin in the bottom left corner of the image
zbuffer.flip_vertically();
image. write_tga_file("output.tga");
zbuffer.write_tga_file("zbuffer.tga");

delete model;
return 0;
}


Давайте його розглянемо детально. Заголовки пропускаємо, потім йдуть глобальні константи: розміри екрану, де знаходиться камера і т. п.
Структуру GouraudShader розберемо в наступному абзаці, пропускаємо. Потім йде безпосередньо main():
  • Читання моделі .obj файл
  • Ініціалізація матриць ModelView, Projection і Viewport (нагадую, самі змінні зберігаються в модулі our_gl)
  • Прохід по моделі та її відображення


В останньому пункті починається найцікавіше. Зовнішній цикл проходить по всіх трикутниках.
Внутрішній цикл проходить по всіх вершин трикутника і для кожної з них викликає вершинний шейдер.

Головне призначення вершинного шейдера — порахувати перетворені координати вершин. Другорядне — підготувати дані для роботи фрагментного шейдера.

Що відбувається після того, як ми викликали вершинний шейдер для всіх вершин в трикутнику? Ми можемо викликати растеризатор нашого трикутника. Що відбувається усередині нього ми не знаємо (не, ну, ми його самі написали, звичайно). Крім однією цікавою речі. Растеризатор трикутника викликає нашу функцію, яку ми йому даємо — фрагментний шейдер. Тобто, ще раз, для кожного пікселя всередині трикутника растеризатор викликає фрагментний шейдер.

Головне призначення фрагментного шейдера — це визначити колір поточного пікселя. Другорядне — ми можемо взагалі відмовитися малювати цей піксель, повернувши true.

Пайплайн OpenGL 2 виглядає так:


Оскільки у нас короткий курс графіки, поки обмежимося цими двома шейдерами. В більш нових версіях OpenGL з'явилися нові види шейдерів, які дозволяють створювати геометрію на льоту. На цій картинці показані синім етапи, які ми програмувати не можемо, а рудим ті, що можемо. За фактом, наша main() — це primitive processing. Вона викликає вершинний шейдер. Складальника примітивів у нас немає, т. до. ми малюємо тупі трикутники безпосередньо (він у нас склеился з primitive processing). Функція triangle() — це растеризатор, для кожної точки вона викликає фрагментний шейдер і потім робить перевірки глибини в z-буфері і так далі.

Всі. Ви знаєте, що таке шейдери, і можете приступати до програмування.

Як працює моє втілення шейдерів на прикладі тонування Гуро

Давайте розберемо ті шейдери, що я привів у коді main.cpp. Як неважко здогадатися, перший шейдер — це тонування Гуро.

Прихований текст

Вершинний шейдер читає вершину .obj файлу, занурює її в чотиривимірний простір (див. попередню статтю), знаходить її екранні координати. Повертає спроецированную в 3д точку, але перед цим вважає коефіцієнт дифузної освітленості для даної вершини і зберігає його у відповідну компоненту вектора varying_intensity.

Ще раз код для зручності:
Прихований текст
Vec3f varying_intensity; // written by vertex shader, read by fragment shader
virtual Vec3i vertex(int iface, int nthvert) {
Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); // read the vertex from .obj file
gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert)*light_dir); // get diffuse lighting intensity
return proj<3>(gl_Vertex/gl_Vertex[3]); // project homogenious coordinates to 3d
}



varying — це зарезервоване слово в мові GLSL, я використовував varying_intensity в якості імені просто щоб підкреслити паралель між ними (про GLSL ми поговоримо в сьомий статті). Ми зберігаємо в структурі varying дані, які будуть екстрапольовані всередині трикутника, і фрагментний шейдер отримає вже інтерпольовані дані.

Розберемо фрагментний шейдер, ще раз код для зручності:
Прихований текст
Vec3f varying_intensity; // written by vertex shader, read by fragment shader
// [...]
віртуальний bool fragment(Vec3f bar, TGAColor &color) {
float intensity = varying_intensity*bar; // interpolate intensity for the current pixel
color = TGAColor(255, 255, 255)*intensity; // well duh
return false; // no, we do not discard this pixel
}



Він викликається растеризатором для кожного пікселя всередині трикутника. Він отримує на вхід барицентричні координати для інтерполяції даних varying_.

Тобто, интерполированная інтенсивність може бути порахована як varying_intensity[0]*bar[0]+varying_intensity[1]*bar[1]+varying_intensity[2]*bar[2] або просто-напросто скалярним добутком між векторами varying_intensity*bar. У цьому GLSL, звичайно, шейдер отримує вже готову значення.

Зверніть увагу, що фрагментний шейдер повертає булевское значення. Його значення легко зрозуміти, якщо подивитися всередину растеризатора (our_gl.cpp, triangle()):
Прихований текст
TGAColor color;
bool discard = shader.fragment(c, color);
if (!discard) {
zbuffer.set(P. x, P. y, TGAColor(P. z));
image.set(P. x, P. y, color);
}



Шейдер може відмовитися малювати даний піксель, тоді растеризатор ігнорує та його z-координату, не оновлюючи z-буфер. Корисно якщо ми хочемо робити бінарні маски або що ще вам в голову прийде.

Зрозуміло, растеризатору і в голову не може прийти, що прийде в голову вам, тому він компілюватися з вашим шейдером заздалегідь не може. Тут нам приходить на допомогу абстрактний клас IShader. Уфф, нечасто я користуюся абстрактними класами, але тут рівно випадок, коли без нього було б погано. Передавати покажчики на функції мені не хочеться зовсім!




Перша модифікація
Прихований текст
virtual bool fragment(Vec3f bar, TGAColor &color) {
float intensity = varying_intensity*bar;
if (intensity>.85) intensity = 1;
else if (intensity>.60) intensity = .80;
else if (intensity>.45) intensity = .60;
else if (intensity>.30) intensity = .45;
else if (intensity>.15) intensity = .30;
else intensity = 0;
color = TGAColor(255, 155, 0)*intensity;
return false;
}



Просто я дозволяю якийсь фіксований набір інтенсивностей освітлення. Ось результат його роботи:
Прихований текст




Текстурируем модель
Тонування Фонга пропускаємо, її докладно розібрали в коментарях, давайте накладемо текстури. Для цього доведеться інтерполювати uv-координати. Нічого нового, просто додаємо матрицю в два рядки (uv) і три стовпці (текстурні координати тре вершин).
Прихований текст
struct Shader : public IShader {
Vec3f varying_intensity; // written by vertex shader, read by fragment shader
mat<2,3,float> varying_uv; // same sa above

virtual Vec3i vertex(int iface, int nthvert) {
varying_uv.set_col(nthvert, model->uv(iface, nthvert));
varying_intensity[nthvert] = std::max(0.f, model->normal(iface, nthvert)*light_dir); // get diffuse lighting intensity
Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); // read the vertex from .obj file
gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
return proj<3>(gl_Vertex/gl_Vertex[3]); // project homogenious coordinates to 3d
}

віртуальний bool fragment(Vec3f bar, TGAColor &color) {
float intensity = varying_intensity*bar; // interpolate intensity for the current pixel
Vec2i uv = varying_uv*bar; // interpolate uv for the current pixel
color = model->diffuse(uv)*intensity; // well duh
return false; // no, we do not discard this pixel
}
};



Прихований текст


Normalmapping

Окей, тепер у нас є текстурні координати. Але адже в текстурах можна зберігати не тільки колір, RGB цілком вистачає для подання xyz!
Давайте завантажимо ось таку структуру, яка для кожного пікселя нашої картинки (а не тільки для вершин, як раніше!) дасть вектор нормалі.
Прихований текст
До речі, порівняйте його з такою картинкою, це та ж сама інформація, але в іншому репере:
Прихований текст

Одна з цих картинок дає нормальні вектори в глобальній системі координат, а інша в дотичній, яка визначається для кожної точки нашого об'єкта. У цій текстурі вектор z — це нормаль до об'єкта, вектор x — це вектор головного напрямку кривизни поверхні, а y — це їх векторний добуток.
Вправа 1 Скажіть, яка з цих текстур дана в глобальних координатах, а яка в дотичних до об'єкту?
Вправа 2 Який формат текстури краще — дотичний або глобальний? Чому?

будь Ласка, не соромтеся (не читаючи коментарів заздалегідь) дати відповіді на ці питання в коментарях!

Прихований текст
struct Shader : public IShader {
mat<2,3,float> varying_uv; // same sa above
mat<4,4,float> uniform_M; // Projection*ModelView
mat<4,4,float> uniform_MIT; // (Projection*ModelView).invert_transpose()

virtual Vec3i vertex(int iface, int nthvert) {
varying_uv.set_col(nthvert, model->uv(iface, nthvert));
Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); // read the vertex from .obj file
gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
return proj<3>(gl_Vertex/gl_Vertex[3]); // project homogenious coordinates to 3d
}

віртуальний bool fragment(Vec3f bar, TGAColor &color) {
Vec2i uv = varying_uv*bar; // interpolate uv for the current pixel
Vec3f n = proj<3>(uniform_MIT*embed<4>(model->normal(uv))).normalize();
Vec3f l = proj<3>(uniform_M *embed<4>(light_dir )).normalize();
float intensity = std::max(0.f, n*l);
color = model->diffuse(uv)*intensity; // well duh
return false; // no, we do not discard this pixel
}
};
[...]
Shader shader;
shader.uniform_M = Projection*ModelView;
shader.uniform_MIT = (Projection*ModelView).invert_transpose();
for (int i=0; i<model->nfaces(); i++) {
Vec3i screen_coords[3];
for (int j=0; j < 3; j++) {
screen_coords[j] = shader.vertex(i, j);
}
triangle(screen_coords, shader, image, zbuffer);
}



Ключове слово uniform в GLSL дозволяє передавати в шейдери константи, тут я в шейдер передав матрицю Projection*Modelview і її зворотний транспоновану для того, щоб перетворити нормальні вектори (див. попередню статтю).
Тобто, все те ж, що і раніше, тільки вектор нормалі ми не интерполируем, а беремо з заготовленої текстури, не забувши при цьому вектор напрямку світла і вектор нормалі перетворити належним чином.

Прихований текст




Блискучі поверхні або specular mapping

Продовжуємо розмову! (Дешевого) обману очі ми використовуємо наближення Фонга для освітлення моделі. Підсумкова засвеченность даної ділянки складається з постійного освітлення всієї сцени (ambient lighting), освітленості для матових поверхонь, які ми вважали досі (diffuse lighting) і освітленості для глянцевих поверхонь (specular lighting):



Засвеченность матових поверхонь ми вважали як косинус кута між вектором і вектором нормалі світла. Тобто, ми припускали, що поверхня розсіює світло приблизно у всіх напрямках. Що відбувається для глянцевих поверхонь? У граничному випадку (для дзеркальних поверхонь) ми маємо засвічування якщо з цього пікселя бачимо джерело світла.

Ось картинка:

Якщо для даної точки освітленість для матових поверхонь ми вважали як косинус кута між векторами n та l, то тепер нам цікавий косинус кута між векторами r (відбите світло) і v (напрямок погляду).

Вправа 3: знайдіть вектор r, маючи вектори n та l
Прихований текстякщо n і l нормалізовані, то r = 2n <n,l> — l

Нагадую, що ми засвічування для матової поверхні вважали як косинус кута. Але ж глянсова відображає джерело в набагато більш сфокусований пучок! Що буде, якщо ми зробимо те ж саме, тільки цей косинус зведемо в десяту ступінь? Нагадую, що числа менше одиниці десятої мірою зменшаться відносно себе ж! Тобто, десята ступінь дасть істотно менший радіус засвітки. А сота ще менший. Ця ступінь зберігається в текстурі, яка дає глянцевость для кожної точки поверхні.

Прихований текст
struct Shader : public IShader {
mat<2,3,float> varying_uv; // same sa above
mat<4,4,float> uniform_M; // Projection*ModelView
mat<4,4,float> uniform_MIT; // (Projection*ModelView).invert_transpose()

virtual Vec3i vertex(int iface, int nthvert) {
varying_uv.set_col(nthvert, model->uv(iface, nthvert));
Vec4f gl_Vertex = embed<4>(model->vert(iface, nthvert)); // read the vertex from .obj file
gl_Vertex = Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
return proj<3>(gl_Vertex/gl_Vertex[3]); // project homogenious coordinates to 3d
}

віртуальний bool fragment(Vec3f bar, TGAColor &color) {
Vec2i uv = varying_uv*bar;
Vec3f n = proj<3>(uniform_MIT*embed<4>(model->normal(uv))).normalize();
Vec3f l = proj<3>(uniform_M *embed<4>(light_dir )).normalize();
Vec3f r = (n*(n*l*2.f) - l).normalize(); // reflected light
float spec = pow(std::max(r.z, 0.0 f), model->specular(uv));
float diff = std::max(0.f, n*l);
TGAColor c = model->diffuse(uv);
color = c;
for (int i=0; i < 3; i++) color[i] = std::min<float>(5 + c[i]*(diff + .6*spec), 255);
return false;
}
};



Власне, тут і пояснювати нічого, крім як коефіцієнтів. У рядку

for (int i=0; i < 3; i++) color[i] = std::min<float>(5 + c[i]*(diff + .6*spec), 255);

Я взяв 5 для ambient, 1 для diffuse та .6 для specular. Які саме брати — вам вирішувати. Це дає враження різних матеріалів. Найчастіше вони дані артистом, але в даному випадку у мене їх немає, тому я взяв приблизно від балди.

Прихований текст

Висновок
Ми навчилися рендери вельми правдобоподбные сцени, але освітлення ще далеко від ідеалу. У наступній статті я розповім про те, що таке shadow mapping. В одній з ортогональних статей я розповім про те, як працює новий растеризатор (ніщо не заважає запустити цей код на старому растеризаторе!).

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

0 коментарів

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