Краткий курс компьютерной графики: пишем упрощённый OpenGL своими руками, статья 2 из 6

Давайте знакомиться, это я.b58d5f0e8f7de59bcad1354b0fc92a35.pngТо есть, модель моей башки, отрендеренная в программе, которую мы сделаем за ближайшие час-два.В прошлый раз мы нарисовали проволочную сетку трёхмерной модели, в этот раз мы зальём полигоны. Точнее, треугольники, так как OpenGL практически любой полигон триангулирует, поэтому ни к чему разбирать сложный случай. Напоминаю, что этот цикл статей создан для самостоятельного программирования. Время, которое я здесь привожу — это не время чтения моего кода. Это время написания вашего кода с нуля. Мой код здесь только для того, чтобы сравнить ваш (рабочий) код с моим. Я совсем не являюсь хорошим программистом, поэтому ваш код может быть существенно лучше моего. Любая критика приветствуется, любым вопросам рад.

Пожалуйста, если вы следуете этому туториалу и пишете свой код, выкладывайте его на github.com/code.google.com и им подобные и давайте ссылки в комментариях! Это может хорошо помочь как и вам (другие люди могут чего посоветовать), так и будущим читателям.

Итак, тема на сегодня (примерно на два часа для плохо программирующих, но мотивированных студентов): отрисовка двумерных треугольников. В прошлый раз мы разобрали алгоритм Брезенхэма для растеризации отрезка, теперь задача нарисовать заполненный треугольник. Вы будете смеяться, но это нетривиальная задача. Я не знаю почему, но я знаю, что это так. Большинство моих студентов без подсказок проводят над этой задачей существенно больше пары часов. Давайте определимся с методом, а затем будем программировать.В самом начале давайте рассмотрим вот такой псевдокод:

triangle (vec2 points[3]) { vec2 bbox[2] = find_bounding_box (points); for (each pixel in the bounding box) { if (inside (bbox, pixel)) { put_pixel (pixel); } } } Я очень люблю этот метод. Он простой и рабочий. Найти описывающий прямоугольник крайне просто, проверить принадлежность точки двумерному треугольнику (да и любому выпуклому полигону) тоже просто.Оффтоп: если мне нужно будет написать код, который будет крутиться на, скажем, самолёте, и этот код должен будет проверять принадлежность точки полигону, я никогда не сяду на этот самолёт. Это на удивление сложная проблема, если мы хотим её решить надёжно.

Почему я люблю этот код? Да потому, что, увидев такое, совсем новичок в программировании его воспримет с энтузиазмом, человек, немного знакомый с программированием, только самодовольно хмыкнет, мол, вот идиот писал. А эксперт в программировании компьютерной графики просто пожмёт плечами, мол, ну да, так оно и работает в реальной жизни. Массивно-параллельные вычисления в тысячах маленьких графических процессоров (я говорю про обычные потребительские компьютеры) творят чудеса. Но мы будем писать код под центральный процессор, поэтому этот метод использовать не будем. Да и какая разница, как оно там в кремнии, нашей абстракции вполне хватит для понимания принципа работы.

Окей, начальная заглушка будет выглядеть следующим образом:

void triangle (Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { line (t0, t1, image, color); line (t1, t2, image, color); line (t2, t0, image, color); }

[…]

Vec2i t0[3] = {Vec2i (10, 70), Vec2i (50, 160), Vec2i (70, 80)}; Vec2i t1[3] = {Vec2i (180, 50), Vec2i (150, 1), Vec2i (70, 180)}; Vec2i t2[3] = {Vec2i (180, 150), Vec2i (120, 160), Vec2i (130, 180)};

triangle (t0[0], t0[1], t0[2], image, red); triangle (t1[0], t1[1], t1[2], image, white); triangle (t2[0], t2[1], t2[2], image, green); 30eb4e46f1904293298677f515d7e97c.png

Как обычно, на гитхабе доступен отпечаток кода. В этом коде всё просто: я даю три треугольника для начальной отладки вашего кода; если внутри функции triangle просто сделать вызов line (), то получим контур треугольника. Как нарисовать заполненный треугольник?

Хороший метод отрисовки треугольника должен обладать следующими свойствами:

Он должен быть (сюрприз) простым и быстрым Он должен быть симметричным: картинка не должна зависеть от порядка вершин, переданных в функцию отрисовки Если два треугольника имеют две общие вершины, между ними не должно быть дырок из-за округлений растеризации. Требований можно добавлять гораздо больше, но мы довольствуемся этими тремя.Традиционно используется line sweeping (заметание отрезком?):

Сортируем вершины треугольника по их y-координате Растеризуем параллельно левую и правую границы треугольника Отрисовываем горзонтальный отрезок между левой и правой точкой границы Тут мои студенты начинают теряться, кто левый, кто правый, да и вообще, в треугольнике три отрезка…В этот момент я оставляю своих студентов примерно на час, чтение моего кода куда как менее ценно, нежели сравнение своего (выстраданного!) кода с моим.

[прошёл час]

Как рисую я? Ещё раз, если у вас есть лучший метод, то я его с огромным удовольствием возьму на вооружение. Давайте предположим, что у нас есть три точки треугольника, t0, t1, t2, они отсортированы по возрастанию y-координаты.Тогда граница А будет между t0 и t2, граница Б будет между t0 и t1, а затем между t1 и t2.

void triangle (Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { // sort the vertices, t0, t1, t2 lower-to-upper (bubblesort yay!) if (t0.y>t1.y) std: swap (t0, t1); if (t0.y>t2.y) std: swap (t0, t2); if (t1.y>t2.y) std: swap (t1, t2);

line (t0, t1, image, green); line (t1, t2, image, green); line (t2, t0, image, red); } Здесь у нас граница А нарисована красным, а граница Б зелёным.

c867b3e2c19c2af991885384f76847c0.png

Граница Б, к сожалению, составная. Давайте отрисуем нижнюю половину треугольника, разрезав его по горизонтали в точке излома границы Б.

void triangle (Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { // sort the vertices, t0, t1, t2 lower-to-upper (bubblesort yay!) if (t0.y>t1.y) std: swap (t0, t1); if (t0.y>t2.y) std: swap (t0, t2); if (t1.y>t2.y) std: swap (t1, t2);

int total_height = t2.y-t0.y; for (int y=t0.y; y<=t1.y; y++) { int segment_height = t1.y-t0.y+1; float alpha = (float)(y-t0.y)/total_height; float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = t0 + (t1-t0)*beta; image.set(A.x, y, red); image.set(B.x, y, green); } } d416a499a0e471b80d05a6488ea1fa4c.png

Заметьте, что в этот раз у меня получились разрывные отрезки. В отличие от прошлого раза (где мы рисовали прямые) я не заморочился поворотом изображения на 90°. Почему? Это оказывается не всем очевидным моментом. Просто если мы соединим горизонтальными линиями соответствующие пары точек, то пробелы пропадут:

a91fa0094cbd7e921d129fbb47eed7b1.png

Теперь осталось отрисовать вторую половину треугольника. Это можно сделать, добавив второй цикл:

void triangle (Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { // sort the vertices, t0, t1, t2 lower-to-upper (bubblesort yay!) if (t0.y>t1.y) std: swap (t0, t1); if (t0.y>t2.y) std: swap (t0, t2); if (t1.y>t2.y) std: swap (t1, t2);

int total_height = t2.y-t0.y; for (int y=t0.y; y<=t1.y; y++) { int segment_height = t1.y-t0.y+1; float alpha = (float)(y-t0.y)/total_height; float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = t0 + (t1-t0)*beta; if (A.x>B.x) std: swap (A, B); for (int j=A.x; j<=B.x; j++) { image.set(j, y, color); // attention, due to int casts t0.y+i != A.y } } for (int y=t1.y; y<=t2.y; y++) { int segment_height = t2.y-t1.y+1; float alpha = (float)(y-t0.y)/total_height; float beta = (float)(y-t1.y)/segment_height; // be careful with divisions by zero Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = t1 + (t2-t1)*beta; if (A.x>B.x) std: swap (A, B); for (int j=A.x; j<=B.x; j++) { image.set(j, y, color); // attention, due to int casts t0.y+i != A.y } } } dda7b251616a80f1cb65690de02e3851.png

На этом можно было бы успокоиться, но у меня случается несварение, когда я дважды вижу один и тот же код, да ещё так рядом. Поэтому сделаем его чуть менее читаемым, зато более простым для модификаций.

void triangle (Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { if (t0.y==t1.y && t0.y==t2.y) return; // i dont care about degenerate triangles // sort the vertices, t0, t1, t2 lower-to-upper (bubblesort yay!) if (t0.y>t1.y) std: swap (t0, t1); if (t0.y>t2.y) std: swap (t0, t2); if (t1.y>t2.y) std: swap (t1, t2); int total_height = t2.y-t0.y; for (int i=0; it1.y-t0.y || t1.y==t0.y; int segment_height = second_half? t2.y-t1.y: t1.y-t0.y; float alpha = (float)i/total_height; float beta = (float)(i-(second_half? t1.y-t0.y: 0))/segment_height; // be careful: with above conditions no division by zero here Vec2i A = t0 + (t2-t0)*alpha; Vec2i B = second_half? t1 + (t2-t1)*beta: t0 + (t1-t0)*beta; if (A.x>B.x) std: swap (A, B); for (int j=A.x; j<=B.x; j++) { image.set(j, t0.y+i, color); // attention, due to int casts t0.y+i != A.y } } } Отпечаток кода для отрисовки 2d треугольников.

Мы умеем уже отрисовывать модель с пустыми треугольниками, давайте их зальём случайным цветом, это поможет нам проверить, насколько хорошо мы закодировали заполнение треугольников. Вот код. for (int i=0; infaces (); i++) { std: vector face = model→face (i); Vec2i screen_coords[3]; for (int j=0; j<3; j++) { Vec3f world_coords = model->vert (face[j]); screen_coords[j] = Vec2i ((world_coords.x+1.)*width/2., (world_coords.y+1.)*height/2.); } triangle (screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor (rand ()%255, rand ()%255, rand ()%255, 255)); } Всё просто: как и раньше, пробегаем по всем треугольникам, превращаем мировые координаты в экранные и рисуем треугольники. Подробное описание разных систем координат в последущих статьях. Должно получиться нечто вроде этого: aad06feadf75e37142b7d4e9bfa212cd.png

Давайте теперь убирать эти клоунские цвета и освещать нашу модель.Капитан Очевидность: «При одной и той же итенсивности света полигон освещён максимально ярко, если свет ему перпендикулярен».Давайте сравним: 4d145477141e08ea0b0ab8310bcc38f7.jpg5fa04ae761ef0f4975bde6145bc94df5.jpgНулевую освещённость мы получим, если полигон параллелен вектору света.Перефразируем: интенсивность освещённости равна скалярному произведению вектора света и нормали к данному треугольнику.Нормаль к треугольнику может быть посчитана просто как векторное произведение двух его рёбер.

for (int i=0; infaces (); i++) { std: vector face = model→face (i); Vec2i screen_coords[3]; Vec3f world_coords[3]; for (int j=0; j<3; j++) { Vec3f v = model->vert (face[j]); screen_coords[j] = Vec2i ((v.x+1.)*width/2., (v.y+1.)*height/2.); world_coords[j] = v; } Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]); n.normalize (); float intensity = n*light_dir; if (intensity>0) { triangle (screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor (intensity*255, intensity*255, intensity*255, 255)); } } Но ведь скалярное произведение может быть отрицательным, что это означает? Это означает, что свет падает позади полигона. Если модель хорошая (обычно не наша забота, а 3д моделеров), то мы просто можем этот треугольник не рисовать. Это позволяет быстро убрать часть невидимых треугольников. В англоязычной литературе называется Back-face culling.

a3af4113710b41eb09ed4b935891da80.pngМодель моей головы выглядит детальнее? Ну так в ней четверть миллиона треугольников. Ничего, детали мы добавим позже, получив картинку, которую я дал для затравки в первой статье.

Обратите внимание, внутренняя полость рта нарисовалась поверх губ. Ну, а что, такое быстрое отсечение невидимых треугольников убирает всё ненужное только для выпуклых моделей. Эти огрехи мы уберём в следующий раз, закодировав z-buffer.Текущая версия рендерера.

© Habrahabr.ru