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

в 15:26, , рубрики: game development, shadowmapping, Программирование, Работа с анимацией и 3D-графикой

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

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

Shadow mapping

Ну вот наш краткий курс подходит к концу, задача на сегодня — научиться отрисовывать тени (внимание, просчёт полутеней — это отдельная тема):
Краткий курс компьютерной графики: пишем упрощённый OpenGL своими руками, статья 6 из 6 - 1
Как всегда, код доступен на гитхабе

До сих пор мы умели затенять выпуклые объекты благодаря нормалям на поверхности, но для невыпуклых объектов наши рендеры давали неверный результат, почему правое (для нас левое) плечо демона освещено? Почему на левой щеке нет тени от рога? Непорядок.
Краткий курс компьютерной графики: пишем упрощённый OpenGL своими руками, статья 6 из 6 - 2

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

Скрытый текст

struct DepthShader : public IShader {
    mat<3,3,float> varying_tri;

    DepthShader() : varying_tri() {}

    virtual Vec4f 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_tri.set_col(nthvert, proj<3>(gl_Vertex/gl_Vertex[3]));
        return gl_Vertex;
    }

    virtual bool fragment(Vec3f bar, TGAColor &color) {
        Vec3f p = varying_tri*bar;
        color = TGAColor(255, 255, 255)*(p.z/depth);
        return false;
    }
};

Этот шейдер просто рисует содержимое z-буфера во фрейм-буфере. Вызываю я этот шейдер из main():

Скрытый текст
    { // rendering the shadow buffer
        TGAImage depth(width, height, TGAImage::RGB);
        lookat(light_dir, center, up);
        viewport(width/8, height/8, width*3/4, height*3/4);
        projection(0);

        DepthShader depthshader;
        Vec4f screen_coords[3];
        for (int i=0; i<model->nfaces(); i++) {
            for (int j=0; j<3; j++) {
                screen_coords[j] = depthshader.vertex(i, j);
            }
            triangle(screen_coords, depthshader, depth, shadowbuffer);
        }
        depth.flip_vertically(); // to place the origin in the bottom left corner of the image
        depth.write_tga_file("depth.tga");
    }

    Matrix M = Viewport*Projection*ModelView;

Я ставлю камеру на место источника света (lookat(light_dir, center, up);) и делаю рендер. Z-буфер этого прохода рендеринга сохранён по указателю shadowbuffer. Обратите внимание, что самой последней строчкой я сохраняю матрицу перехода из координат объекта в экранные координаты.

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

Скрытый текст

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

Второй проход я делаю при помощи другого шейдера:

Скрытый текст

struct Shader : public IShader {
    mat<4,4,float> uniform_M;   //  Projection*ModelView
    mat<4,4,float> uniform_MIT; // (Projection*ModelView).invert_transpose()
    mat<4,4,float> uniform_Mshadow; // transform framebuffer screen coordinates to shadowbuffer screen coordinates
    mat<2,3,float> varying_uv;  // triangle uv coordinates, written by the vertex shader, read by the fragment shader
    mat<3,3,float> varying_tri; // triangle coordinates before Viewport transform, written by VS, read by FS

    Shader(Matrix M, Matrix MIT, Matrix MS) : uniform_M(M), uniform_MIT(MIT), uniform_Mshadow(MS), varying_uv(), varying_tri() {}

    virtual Vec4f vertex(int iface, int nthvert) {
        varying_uv.set_col(nthvert, model->uv(iface, nthvert));
        Vec4f gl_Vertex = Viewport*Projection*ModelView*embed<4>(model->vert(iface, nthvert));
        varying_tri.set_col(nthvert, proj<3>(gl_Vertex/gl_Vertex[3]));
        return gl_Vertex;
    }

    virtual bool fragment(Vec3f bar, TGAColor &color) {
        Vec4f sb_p = uniform_Mshadow*embed<4>(varying_tri*bar); // corresponding point in the shadow buffer
        sb_p = sb_p/sb_p[3];
        int idx = int(sb_p[0]) + int(sb_p[1])*width; // index in the shadowbuffer array
        float shadow = .3+.7*(shadowbuffer[idx]<sb_p[2]); 
        Vec2f uv = varying_uv*bar;                 // interpolate uv for the current pixel
        Vec3f n = proj<3>(uniform_MIT*embed<4>(model->normal(uv))).normalize(); // normal
        Vec3f l = proj<3>(uniform_M  *embed<4>(light_dir        )).normalize(); // light vector
        Vec3f r = (n*(n*l*2.f) - l).normalize();   // reflected light
        float spec = pow(std::max(r.z, 0.0f), model->specular(uv));
        float diff = std::max(0.f, n*l);
        TGAColor c = model->diffuse(uv);
        for (int i=0; i<3; i++) color[i] = std::min<float>(20 + c[i]*shadow*(1.2*diff + .6*spec), 255);
        return false;
    }
};

Это практически один-в-один шейдер из конца предыдущей статьи, за одним исключением:
я объявил константную матрицу, которая не меняется во время работы ни вершинного, ни фрагментного шейдеров mat<4,4,float> uniform_Mshadow.

Эта матрица позволит мне превратить экранные координаты текущего шейдера в экранные координаты уже отрисованного теневого буфера!
О том, как мы её считаем, в следующем абзаце. Давайте посмотрим, как мы её используем, обратим внимание на вот эти четыре строчки шейдера:

        Vec4f sb_p = uniform_Mshadow*embed<4>(varying_tri*bar); // corresponding point in the shadow buffer
        sb_p = sb_p/sb_p[3];
        int idx = int(sb_p[0]) + int(sb_p[1])*width; // index in the shadowbuffer array
        float shadow = .3+.7*(shadowbuffer[idx]<sb_p[2]);

varying_tri*bar даёт мне экранные координаты текущего фрагмента, что мы отрисовываем. Мы их погружаем в однородные координаты, преобразовываем нашей магической матрицей uniform_Mshadow и та-дам, мы знаем xyz координаты в пространстве теневого шейдера, что мы использовали для первого прохода. Теперь для того, чтобы понять, освещена данная точка или нет, нам достаточно сравнить её z-координату и значение z-буфера из первого прохода!

Как выглядит вызов второго шейдера в main()? Всё достаточно стандартно:

    Matrix M = Viewport*Projection*ModelView;

    { // rendering the frame buffer
        TGAImage frame(width, height, TGAImage::RGB);
        lookat(eye, center, up);
        viewport(width/8, height/8, width*3/4, height*3/4);
        projection(-1.f/(eye-center).norm());

        Shader shader(ModelView, (Projection*ModelView).invert_transpose(), M*(Viewport*Projection*ModelView).invert());
        Vec4f screen_coords[3];
        for (int i=0; i<model->nfaces(); i++) {
            for (int j=0; j<3; j++) {
                screen_coords[j] = shader.vertex(i, j);
            }
            triangle(screen_coords, shader, frame, zbuffer);
        }
        frame.flip_vertically(); // to place the origin in the bottom left corner of the image
        frame.write_tga_file("framebuffer.tga");
    }

Напоминаю, что матрица M — это матрица преобразования координат объекта в экранные координаты теневого буфера. Мы ставим камеру на место, где она и должна быть, настраиваем вьюпорт и параметры перспективной проекции, и объявляем шейдер второго прохода рендеринга.

Мы знаем, что Viewport*Projection*ModelView — это матрица преобразования координат объекта в экранные координаты второго шейдера. Но нам надо знать матрицу преобразования экрана второго шейдера в экран первого шейдера. Это просто: (Viewport*Projection*ModelView).invert() преобразует экран второго шейдера в объектные координаты, а затем умножим просто на М, получив финальную матрицу преобразования как M*(Viewport*Projection*ModelView).invert().

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

Скрытый текст

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

Что это? Этот артефакт известен как борьба за z. Если пиксель должен быть освещён, то именно его z-координата должна быть в z-буфере теневого шейдера. Или это должно быть z-значение соседнего пикселя? В общем, разрешения нашего z-буфера не хватает, чтобы дать картинку без артефактов. Мы будем бороться с этой проблемой методом грубой силы:

        float shadow = .3+.7*(shadowbuffer[idx]<sb_p[2]+43.34); // magic coeff to avoid z-fighting

Я сдвигаю один z-буфер относительно другого на некую константу, которой достаточно, чтобы этот артефакт исчез. Да, это порождает новые артефакты (какие?), но существенно менее заметные глазу. Всё, результат работы нашей программы виден на заглавной картинке.

Поздравляю, наш краткий курс подошёл к концу. Мы с нуля написали весьма неплохой, как мне кажется, аналог OpenGL.

А ты записался добровольцем?

В качестве бонуса к краткому курсу в следующий раз я покажу, как считать касательный базис к нашей поверхности (чтобы использовать текстуры, заданные в tangent space) и заодно напишем простой шейдер, умеющий работать со светящимися объектами (см. кристалл в голове у диаблы):
Краткий курс компьютерной графики: пишем упрощённый OpenGL своими руками, статья 6 из 6 - 5

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

Автор: haqreu

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js