Пользователь
24,8
рейтинг
21 января 2015 в 14:46

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

Содержание курса



Улучшение кода






Official translation (with a bit of polishing) is available here.




А что потом? Я разобрал весь материал!



В статьях 7 и 8 мы поговорим о программировании непосредственно под OpenGL. Есть ненулевая вероятность получить краткий курс OpenCL/CUDA в статьях 9+.

Удаление невидимых поверхностей


Знакомьтесь, это мой друг z-buffer головы абстрактного африканца. Он нам поможет убрать визуальные артефакты отбрасывания задних граней, которые у нас оставались в прошлой статье.



Кстати, не могу не упомянуть, что эта модель, которую я использую в хвост и в гриву, была любезно предоставлена замечательным Vidar Rapp.

Мы её можем использовать исключительно в рамках обучения рендерингу. Это очень качественная модель, с которой я варварски обошёлся, но я обещаю вернуть ей глаза!

В теории можно не отбрасывать невидимые грани, а просто рисовать всё подряд, начав с самых задних, и заканчивая передними.

Это называется алгоритмом художника. К сожалению, он весьма затратен, на каждое изменение положения камеры нужно пересортировывать сцену. А бывают ещё и динамические сцены… Но даже не это основная проблема. Проблема в том, что не всегда это можно сделать.



Перед отрисовкой головы отрисуем что попроще


Давайте представим себе простейшую сцену из трёх треугольников, камера смотрит сверху вниз, мы проецируем наши треугольники на белый экран:



Вот так должен выглядеть рендер этой сцены:



Синяя грань — она за красной или перед? Ни то, ни то. Алгоритм художника здесь ломается. Ну, то есть, можно синюю грань разбить на две, одна часть перед красной, другая за. А та, что перед красной, ещё на две — перед зелёной и за зелёной… Думаю, достаточно ясно, что в сценах с миллионами треугольников это быстро становится непростой задачей. Да, у неё есть решения, например, пользоваться двоичными разбиениями пространства, заодно это помогает и для сортировки при смене положения камеры, но давайте не будем себе усложнять жизнь!



Три измерения — это слишком. Y-buffer!


Давайте потеряем одно из измерений, рассмотрим двумерную сцену, полученную пересечением нашей сцены и жёлтой плоскости разреза:

То есть, наша сцена состоит из трёх отрезков (пересечение жёлтой плоскости и каждого из треугольников), а её рендер — это картинка
той же ширины, что и нормальный рендер, но в один пиксель высотой:


Снимок кода, как обычно, на гитхабе. Поскольку у нас сцена двумерная, то её очень просто нарисовать, это просто три вызова функции line(), которую мы запрограммировали в самый первый раз.

    { // just dumping the 2d scene (yay we have enough dimensions!)
        TGAImage scene(width, height, TGAImage::RGB);

        // scene "2d mesh"
        line(Vec2i(20, 34),   Vec2i(744, 400), scene, red);
        line(Vec2i(120, 434), Vec2i(444, 400), scene, green);
        line(Vec2i(330, 463), Vec2i(594, 200), scene, blue);

        // screen line
        line(Vec2i(10, 10), Vec2i(790, 10), scene, white);

        scene.flip_vertically(); // i want to have the origin at the left bottom corner of the image
        scene.write_tga_file("scene.tga");
    }


Вот так выглядит наша двумерная сцена, наша задача посмотреть на эти отрезки сверху.


Давайте теперь её рендерить. Напоминаю, рендер — это картинка шириной во всю сцену и высотой в один пиксель. В моём коде я её объявил высотой в 16, но это чтобы не ломать глаза, рассматривая один пиксель на экранах высокого разрешения. Функция rasterize пишет только в первую строчку картинки render.

        TGAImage render(width, 16, TGAImage::RGB);
        int ybuffer[width];
        for (int i=0; i<width; i++) {
            ybuffer[i] = std::numeric_limits<int>::min();
        }
        rasterize(Vec2i(20, 34),   Vec2i(744, 400), render, red,   ybuffer);
        rasterize(Vec2i(120, 434), Vec2i(444, 400), render, green, ybuffer);
        rasterize(Vec2i(330, 463), Vec2i(594, 200), render, blue,  ybuffer);


Итак, я объявил загадочный массив ybuffer ровно в размер нашего экрана (width, 1). Этот массив инициализирован минус бесконечностью. Затем я передаю в функцию rasterize и картинку render, и этот загадочный массив. Как выглядит сама функция?

void rasterize(Vec2i p0, Vec2i p1, TGAImage &image, TGAColor color, int ybuffer[]) {
    if (p0.x>p1.x) {
        std::swap(p0, p1);
    }
    for (int x=p0.x; x<=p1.x; x++) {
        float t = (x-p0.x)/(float)(p1.x-p0.x);
        int y = p0.y*(1.-t) + p1.y*t;
        if (ybuffer[x]<y) {
            ybuffer[x] = y;
            image.set(x, 0, color);
        }
    }
}


Очень-очень просто: я прохожу по всем x-координатам между p0.x и p1.x и вычисляю соответствующую y-координату нашей линии.
Затем я проверяю, что у нас в массиве ybuffer по этой координате x. Если текущий пиксель ближе к камере, нежели то, что там сохранено,
то я и его рисую в картинке, и ставлю новую y-координату в игрек-буфере.

Давайте разбираться поэтапно: после вызова растеризатора для первой (красной) линии вот что мы имеем в памяти:

содержимое экрана:


содержимое y-буфера:

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

Дальше мы рисуем зелёную линию, вот память после вызова её растеризатора:

содержимое экрана:



содержимое y-буфера:



Ну и напоследок синюю:

содержимое экрана:



содержимое y-буфера:



Поздравляю вас, мы нарисовали нашу двумерную сцену! Ещё раз полюбуемся на финальный рендер:






Три измерения — это в самый раз. Z-buffer!


Снимок кода на гитхабе.

Внимание: в этой статье я использую ту же самую версию растеризатора треугольника, что и в предыдущей. Улучшенная версия растеризатора (проход всех пикселей описывающего прямоугольника) будет вскорости любезно предоставлена и описана в отдельной статье уважаемым gbg! Stay tuned.



Поскольку у нас экран теперь двумерный, то z-буфер тоже должен быть двумерным:
int *zbuffer = new int[width*height];

Я упаковал двумерный массив в одномерный, конвертировать можно как обычно:
из двух координат в одну:
int idx = x + y*width;


Обратно:
int x = idx % width;
int y = idx / width;


Затем в коде я прохожу по всем треугольникам и делаю вызов растеризатора, передавая ему и картинку, и z-буфер.

triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255), zbuffer);

[...]

void triangle(Vec3i t0, Vec3i t1, Vec3i t2, TGAImage &image, TGAColor color, int *zbuffer) {
    if (t0.y==t1.y && t0.y==t2.y) return; // i dont care about degenerate triangles
    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; i<total_height; i++) {
        bool second_half = i>t1.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
        Vec3i A =               t0 + Vec3f(t2-t0)*alpha;
        Vec3i B = second_half ? t1 + Vec3f(t2-t1)*beta : t0 + Vec3f(t1-t0)*beta;
        if (A.x>B.x) std::swap(A, B);
        for (int j=A.x; j<=B.x; j++) {
            float phi = B.x==A.x ? 1. : (float)(j-A.x)/(float)(B.x-A.x);
            Vec3i P = Vec3f(A) + Vec3f(B-A)*phi;
            int idx = P.x+P.y*width;
            if (zbuffer[idx]<P.z) {
                zbuffer[idx] = P.z;
                image.set(P.x, P.y, color);
            }
        }
    }
}

Это просто ужасно, насколько код похож на растеризатор из прошлой статьи. Что изменилось? (Используйте vimdiff и посмотрите).
Vec2 был заменён на Vec3 в вызове функции и сделана проверка if (zbuffer[idx]<P.z);
Всё! Вот наш настоящий рендер без огрехов отсечения невидимых поверхностей:



Обратите внимание, что backface culling в моём коде оставлен:
if (intensity>0) {
    triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255), zbuffer);
}

Он не является необходимым для получения этой картинки, это только ускорение вычислений.




Стоп, мы только что интерполировали z-координату. А можно добавить ещё чего в нагрузку?


Текстуры! Это будет домашняя работа.

В .obj файле есть строчки vt u v, они задают массив текстурных координат.
Среднее число между слешами в f x/x/x x/x/x x/x/x — это текстурные координаты данной вершины в данном треугольнике. Интерполируете их внутри треугольника, умножаете на ширину-высоту текстурного файла и получаете цвет пикселя из файла текстуры.
Диффузную текстуру брать здесь.

Вот пример того, что должно получиться:


Update:


Решение домашки доступно здесь
@haqreu
карма
250,2
рейтинг 24,8
Пользователь
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (53)

  • +3
    Ура! Я правда не поспеваю за вами, но частота выхода статей стимулирует не бросать начатое ))
  • +1
    А можете пояснить суть этого хака:
    P.x = j; P.y = t0.y+i; // a hack to fill holes (due to int cast precision problems),
    
    ? О каких дырках речь, и почему в прошлой части подобный хак был не нужен?
    • 0
      В прошлых версиях он присутствовал, вот код из конечного кода второй главы: image.set(j, t0.y+i, color); // attention, due to int casts t0.y+i != A.y
      А дырки… Сейчас попробую картинку сделать.
    • +4
      Вот что мы получаем, если закомментировать вот эту строчку с комментарием про хак:


      Проблема в том, что при вычислении координат текущего пикселя я не округляю плавающую точку до целого, я кастую в целое.
      int(.999) = 0
      вот в этой строке кода происходит ужас:
      Vec3i P = A + (B-A)*phi;

      Смешно в том, что иксовую коорднату (j) и игрековую координату я точно знаю (t0.y+i). А реально я точку P вычисляю для z (в последующих статьях ещё для текстурных координат, нормальных векторов и проч), где мне такая точность ни к чему. То есть, я поправляю x и y до правильных, а z остаётся как есть.
      • 0
        Понял Вас, спасибо большое. Насколько понимаю, в реальном коде лучше не разбрасываться ресурсами, а произвести напрямую операции над координатой Z
        • +1
          Разумеется. Просто я экономлю строки кода, и оставшуюся информацию нагружу внутрь P. Она впоследствии будет не Vec3, а структурой с кучей полей. При этом я изменю только заголовок функции растеризатора, всё остальное останется нетронутым.
    • 0
      Я переписал код, теперь там есть аккуратное округление. Должно стать читаемее.
      • 0
        Простите, знаю c++ только на уровне «могу читать код и примерно понимать работу операторов» — а где добавилось округление в коде?
        • +1
          в файле geometry.h я добавил конструктор, который из Vec3f строит Vec3i с округлением:
          template <> template <> vec<3,int>  ::vec(const vec<3,float> &v) : x(int(v.x+.5f)),y(int(v.y+.5f)),z(int(v.z+.5f)) {}
          

          Если мы имеем число 0.9 в переменной типа float:
          float a = .9;

          То простой каст в целое число просто отбросит дробную часть, int (a) равен нулю. int(a+0.5) округлит.
          • 0
            Понял. Тоже подумал про манипуляции внутри векторов, однако предполагал, что есть просто стандартные функции округления чисел, которые лучше использовать вместо кастований.
            • 0
              Вы будете смеяться, но в c98 функции округления нет, появилась в c11.
              • 0
                А можно еще следующий вопрос: я, видимо, упустил, или не понимаю, но хуже не знать, чем спросить:
                1. Непонятно, почему два массива текстурных координат, vt и f? И как понять в случае f, текстурная координата какого треугольника имеется ввиду? Возможно, я просто неверно понимаю формат этого файла
                2. И второй момент, что есть интерполяция координаты? Как-то не увидел этот момент в статьях
                • 0
                  v и vt задают вершины в геометрическом и в текстурном пространствах, соответственно.
                  f задаёт связь между этими вершинами, в строчках f хранятся индексы из массивов вершин v и vt.

                  Я не очень понял второй вопрос, можете пояснить?
                  • 0
                    В f связи — это через слеш индекс в v, индекс в vt, и индекс в vn?

                    По второму вопросу: и до домашки есть следующая фраза: «Стоп, мы только что интерполировали z-координату». И вот мне непонятно, где мы ее интерполировали? Гугль выдает немало ссылок, но не знаю, что именно почитать, чтоб понять этот термин в отношении координаты или Вашего рендерера.
                    • +1
                      Про строчки с f совершенно верно.

                      Про z-координату надо смотреть на вот эту строчку:
                                  Vec3i P = Vec3f(A) + Vec3f(B-A)*phi;
                      


                      Реально меня интересует z-координата точки P, т.к. x и y мы уже знаем (j, t0.y+i). Поэтому можно было бы написать просто что-то типа

                                  int z = A.z + (B.z-A.z)*phi;
                      


                      Это и есть интерполяция координаты z для треугольника t0,t1,t2:
                      мы посчитали z-координату точки A и точки B, найдя соответствующие alpha и beta (это линейная интерполяция).
                      Затем мы посчитали z для текущего пикселя, сделав линейную интерполяцию между A.z и B.z.

                      В сумме получилось, что z каждого пикселя считается билинейной интерполяцей координат t0.z, t1.z, t2.z.

                      Абсолютно точно так же можно поступить с uv координатами.
                      • 0
                        То есть phi у Вас — это просто множитель в формуле линейной интерполяции image (тут немного иначе записано, фактически множитель (x-x0)/(x1-x0)), соответственно мы при расчете не думаем в рамках третьего измерения, а просто поступаем с z, как поступили бы, скажем, с х-координатой, верно я понял смысл?
                        • +1
                          Абсолютно верно.
                          • 0
                            Ох, я не безнадежен :) Спасибо большое.
  • +2
    Захотелось немного и своими экспериментами поделиться. Пару лет назад писал рендерер на JS, просто чтобы удостовериться в соем понимании сути происходящего:

    Картинки
    image
    image


    А статьи отличные. Надеюсь, шестью частями все не ограничится, и после них последуют какие-нибудь плюшки про более частные вещи: основные принципы АА или BVH, к примеру.
    • 0
      Прикольно. А как это сделано? Рейтрейсер рендерер элементарно рисует отражения/тени. Я не знаю как такое делать шейдерами.
      • 0
        Так рейтрейсингом и сделано, собственно.
    • +3
      Не могу не процитировать код рейтрейсера, умещающийся на визитке.
      1984 год, однако:
      • 0
        Рейтрейсеры вообще лего писать ведь. У них проблема спроизводительностью, что довольно плохо, если скажем, игры делаем.
        • +1
          Напишете код, который влезет на визитку? :)
          А вообще зависит от применения рейтрейсера. Может быть вполне эффективно. Я буду рассказывать о примитивном рейтрейсере (в фрагмент шейдере) в статье 8.
  • +1
    Экспериментирую с z-буфером. Вот что будет если P.z = A.z;
    Картинка
    image
    • 0
      У вас точно такая же ошибка, как и тут. Только у вас забыта z-координата при сортировке, а не цвет.
      • 0
        Хотя стоп, нет, я неправ. Всё у вас хорошо, нужно только продолжать интерполировать внутри треугольника.
  • +1
    В общем, вот такая штука у меня получилась:
    Картинка
    image

    Не пойму, то ли с UV-координатами у меня что-то не то, то ли дело просто в том, что я не разбирался еще с резкими переходами и поэтому так кажется.
    Слепок кода вот тут: github.com/FunkyCat/3dHabraLessons/tree/546c0ab3eda58e746de15fd1fe737648a9b63af0 (осторожно, VS 2013 :) )
    • 0
      Мм, трудно сказать, можете дать скриншот без текстуры? Я поищу компилятор, способный сжевать ваш проект, но пока под руками c11 у меня нет :(
      • +1
        За c11 извиняюсь — на работе так устаю от 03 плюсов, что в своих проектах отвожу душу :)
        Без текстуры
        image
        • 0
          Так, з-буфер отличный, а вот с текстурой есть пара треугольников, которые меня смущают. Ну что, сейчас сам сяду домашку делать :)
        • 0
          Закончил с домашкой, Вот слепок кода
        • 0
          Не сочтите за придирку, но почему вы не использовали автоцикл по коллекции ('ranged for')?
          main.cpp, строка 133:
          for (auto iter = fx.begin(); iter != fx.end(); iter++) {
          
          • 0
            Просто потому, что пока очень мало работал с новыми стандартами, и забыл про эту возможность :) Спасибо за замечание, поправил.
            • 0
              На С++11 (благодаря спискам инициализации и шаблонам с переменным количеством аргументов), этот код (после полного перетряхивания) становится очень симпатичным.

              Ах да, на самом распоследнем GCC (4.9) работает regex. Можно существенно сократить парсер *.obj и добавить ему читаемости.
    • 0
      Да, точно у вас проблемы с UV, вот я обвёл треугольники, которые бросаются в глаза. Сейчас попробую ваш код посмотреть
      • 0
        Да, я уже понял в чем проблема по вашему домашнему задания — я забыл свапнуть uvA и uvB, когда A.x > B.x :) Спасибо!
        Сейчас вот так:
        Картинка
        image

        • 0
          Хехе, получил сообщение о входящем комментарии ровно когда писал ответ об этой строчке кода :)
          Теперь хорошая текстура
        • 0
          Теперь попробуйте использовать сначала нормальные вектора к вершинам, а не к треугольникам. Они сохранены в .obj файле в строчках vn x y z и
          f x/x/IVN1 x/x/IVN2 x/x/IVN3

          Передаёте ровно как текстурные координаты внутрь растеризатора и интерполируете.
          Интенсивность считаете внутри растеризатора в зависимости от интерполированной нормали.
          Получите тонировку Фонга.
          • 0
            Да, именно этим и займусь, если решу окончательно не готовиться к завтрашнему экзамену :) Хотя звучит довольно просто.
          • 0
            Я попробовал и получил.

            image

            Правда, не сразу. Когда мы делали плоское освещение, наш источник света имел координаты (0, 0, -1); Т.е. координата Z возрастала в направлении «от нас». Нормали модели (внутри *.obj) наоборот сохранены в пространстве с направлением Z «к нам». Тогда источник света должен иметь координаты (0, 0, 1), что соответствует системе координат openGL, но не соответствует нашим изначально выбранным.

            Чтобы работали оба случая, по-быстрому можно поменять знак нормали для каждой грани (для случая плоского освещения):
            Vec3f n = (world_coords[1] - world_coords[0]) ^ (world_coords[2] - world_coords[0]); // поменять местами множители
            n.normalize(); 
            float intensity = n * light_dir;
            
    • 0
      Удалено, промах веткой.
  • 0
    Господа, а есть у кого идеи, как реализовать z-буфер на JS+Canvas, если пользоваться для отрисовки канвасовскими «путями», а не рисовать самому поточечно?
    • 0
      Не думаю, что это возможно. Вы хотите сделать z-буфер не для точек, а для целых треугольников? Нормальной отрисовки так не добиться.
      Да и таким методом вы даже не сможете тонирование потом сделать, поэтому лучше сразу делать точечную отрисовку.
      Достаете back buffer (getImageData()) из canvas, полноразмерный, пишите в него поточечно, затем копируете в canvas с помощью putImageData().
      Могу кинуть свой вариант такого движка на js.
    • 0
      Рисование ненативных треугольников, хоть и медленнее, но всё же вполне приемлемо.
      Демка с z-буфером: jsfiddle.net/2wvyga24/24/
      • 0
        Ну, так-то оно работает, конечно, но для плавной анимации мощностей не хватает. Вот, сами гляньте:
        godlin.ucoz.ru/3D/index1.html

        Если водить мышкой — меняется освещение.
        • 0
          В статье представлен относительно простой, но не очень производительный растеризатор. Если вам действительно принципиально написать софт движок так, чтобы на js в реал тайме был высокий фпс, то попробуйте другие алгоритмы.
        • 0
          Ну так-то да.
          Такое рисование явно годится лишь для своих экспериментов и получения опыта, а если нужно что-то реальное, лучше взять WebGL.
    • 0
      Ещё можно воспользоваться алгоритмом художника, отсортировав полигоны по z-координате.
  • 0
    Я знаю, что вы компилируете на Linux, я к сожалению не могу скомпилировать код в Visual studio. Ругается на Vec3 (to few arguments) в файле geometry.cpp. Можно скомпилировать другим компилятором (g++, clang) но хотелось бы просто поправить код.
    • 0
      У меня такое впечатление, что это баг вижл студии, причём конкретной версии. Я прекрасно компилировал под виндой с разными версиями, но некоторые люди жаловались, что у них не компилируется. Лично я баг воспроизвести не могу, к сожалению. Простой способ поправить — убрать кастомные конструкторы, которые обеспечивают приведение типов (в geometry.cpp)
  • 0
    Всем привет!, haqreu, большое спасибо за этот классный курс статей. Я пытаюсь сделать рендерер на C#, был бы признателен за любую критику/ревью кода.

    Репозиторий: github.com/coremission/elRenderer

    У меня есть проблема с текстурированием, в одном месте на макушке странный артефакт и глаза пустые, не могу понять где именно ошибка, просмотрел комментарии во всех статьях цикла и подобной не встретил:

    Артефакт при текстурировании
    image
    (чуть повернутая картинка для лучшей демонстрации «артефакта», без перспективных искажений)
    • 0
      Критика кода простая — начиная с полного отсутствия комментариев в тексте (совершенно демотивирует искать, где же у вас там притаился растеризатор и чтение текстуры) и заканчивая фатальной неисправностью вашего компилятора, из которого монтировкой выломали циклы — разгребать кирпич кода умножения матриц на предмет, а не попутал ли автор индекс или знак — удовольствие ниже среднего.

      Посмотрите мои статьи в этом курсе, с описанием того, как это здорово делается на CPP и перенесите эти идеи на ваш тотемный язык.

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