为渲染器添加着色器( Shaders for the software renderer )

前言:第 6 课,这一章超级长。为了静下心来我又翻译了这篇文章

回想一下,我在这里的所有源代码都要与你的源代码进行比较。不要使用我的代码,写自己的代码。我是一个糟糕的程序员。请做最疯狂的着色器并发送给我图像,我会在这里发布。

首先,让我们检查一下源代码的当前状态

  • geometry.cpp+.h — 218 lines
  • model.cpp+.h — 139 lines
  • our_gl.cpp+.h — 102 lines
  • main.cpp — 66 lines

总共 525 行,正是我们想要的。请注意,负责实际渲染的唯一文件位于 our_gl. 和 main.cpp 中,共有 168 行*。

重构源代码(Refactoring the source code)

好的,我们的 main.cpp 太多了,让我们把它分成两部分:

  • our_gl.cpp + h 这部分程序员无法触及:粗略地说,它是 OpenGL 库文件

  • main.cpp - 在这里我们可以编程我们想要的一切。

现在,要把什么放入了 our_gl? ModelView,Viewport 和Projection 矩阵以及初始化函数和三角形光栅化器。就这些!

这是文件 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();
    virtual Vec3i vertex(int iface, int nthvert) = 0;
    virtual bool fragment(Vec3f bar, TGAColor &color) = 0;
};

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

文件 main.cpp 现在只有 66 行,因此我完整地列了出来(不好意思啊,代码很长,但是我太喜欢这代码了,所以我要列出来):

#include 
#include 

#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 Vec4f vertex(int iface, int 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
        return Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
    }

    virtual 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; infaces(); i++) {
        Vec4f 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 ,所以让我们跳过它。接下来就是实际的主函数:

  • 转换 .obj 文件
  • 初始化 ModelView, Projection and Viewport 矩阵。(回想一下,这些矩阵都是在 our_gl 模块中)
  • 迭代模型的所有三角形和每个三角形的光栅化。

最后一步特别好玩。外循环遍历所有三角形。内循环遍历当前三角形的所有顶点,并用顶点着色器给每个顶点着色。

顶点着色器的主要目的是去转换顶点的坐标,次要目的是为像素着色器提供数据

那之后发生了什么?我们可以称之光栅化例程。光栅器里面发生了什么我不知道(好吧,其实我们知道,因为我们实现了它)我知道我们的光栅器调用了给每一个像素调用了一个函数。这个函数被叫做

像素着色器(the fragment shader)。对于每一个在三角形的像素来说,都会调用像素着色器

像素着色器的主要目的是决定当前像素的颜色,次要目的是通过返回 true 丢弃当前像素

OpenGL 2 的渲染管道可以由下图表示

由于我课程的限制,我缩减了 OpenGL 2 的管道,仅有片段着色器和顶点着色器。在较新的版本中,还有其他着色器

好的,在上面的图像中,我们无法触摸的所有阶段都以蓝色显示,而我们的回调以橙色显示。实际上,我们的 main() 函数(之前写的)是原始处理例程。它调用顶点着色器。在之前的代码中,我们没有「primitive assembly」。因为我们只是画出了哑三角形(在我们的代码中,它合并了「primitive processing 」)。triangle() 函数 - 是光栅器。对于在三角形内的每一点会调用像素着色器,然后用 zbuffer 检查

结束了!你知道着色器是什么现在你可以创建自己的着色器!

实现一个高洛德着色器(My implementation of shaders shown on Gouraud shading)

让我们检查一下我在 main.cpp 中列出的着色器。根据它的名字,它是一个 Gouraud 着色器。让我重新列出代码:

Vec3f varying_intensity; // written by vertex shader, read by fragment shader
    virtual Vec4f vertex(int iface, int 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
        return Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
}

varying 是 GLSL 语言中保留的关键字。我使用了varying_intensity作为名字,与 GLSL 对应。在不同的变量中我们存储了数据用来在三角形中插入,像素着色器得到插入的值(给每一个像素)

让我们重新列出像素着色器:

  Vec3f varying_intensity; // written by vertex shader, read by fragment shader
// [...]
    virtual 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_intensity[0]*bar[0]+varying_intensity[1]*bar[1]+varying_intensity[2]*bar[2]

或者仅仅作为两个向量之间的点积:varying_intensity * bar。当然,在真正的GLSL中,像素着色器接收准备插值。

请注意着色器返回 bool 值。如果我们查看光栅化器(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);
}

像素着色器可以丢弃当前像素的绘制,然后光栅化器只是跳过它。如果我们想要创建二进制掩码或任何你想要的东西,这是很方便的(请查看第 9 课,丢弃像素是一个非常酷的例子)。

当然,光栅器无法画出你编程的所有奇怪的东西,因此无法使用着色器进行预编译。这里我们使用抽象类 IShader 作为两者之间的中间体。哇,我很少使用抽象类,但没有它我们会在这里受苦。功能指针很难看。

着色器的第一次修改(First modification of the shaders)

  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;
  }

简单修改Gourad着色,其中强度只允许6个值,结果如下:

纹理(Textures)

我会跳过 Phong 阴影,但看一下这篇文章。还记得我给你的纹理作业吗?我们必须插入uv坐标。所以,我创建了一个2x3矩阵。 u和v为2行,3列(每个顶点一列)。

struct Shader : public IShader {
    Vec3f          varying_intensity; // written by vertex shader, read by fragment shader
    mat<2,3,float> varying_uv;        // same as above

    virtual Vec4f 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
        return Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
    }
    
    virtual bool fragment(Vec3f bar, TGAColor &color) {
        float intensity = varying_intensity*bar;   // interpolate intensity for the current pixel
        Vec2f 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)

好的,现在我们有纹理坐标。我们可以在纹理图像中存储什么?事实上,几乎任何事情。它可以是颜色,方向,温度等。让我们加载这个纹理:

[图片上传失败...(image-a19ab-1553651684120)]

如果我们将 RGB 值解释为 xyz 方向,则此图像为渲染的每个像素提供法线向量,而不是像以前那样为每个顶点提供法向向量。

顺便说一句,将此图像与另一个图像进行比较,它会提供完全相同的信息,但在另一个框架中:

其中一个图像在全局(笛卡尔)坐标系中给出法向矢量,在Darboux 框架(切线空间)中给出另一个矢量。在Darboux框架中,z - 向量垂直于物体,x - 主曲率方向和y - 它们的叉积。

第一个作业:

找出哪个图片属于 Darboux frame 哪个图片属于 global coordinate frame?

第二个作业:

哪个好?为什么。

镜面映射(Specular mapping)

所有的计算机图形科学都是欺骗的艺术。为了(廉价地)欺骗眼睛,我们使用了 Phong 对照明模型的近似。

Phong建议将最终照明视为三种光强度的(加权)总和:环境照明(每个场景的常数),漫射照明(我们计算到目前为止的那个)和镜面照明。 看看下面的图片,它说明了一切:

我们计算漫反射光照作为法向矢量和光方向矢量之间角度的余弦。我的意思是,这假设光线在所有方向上均匀地反射。有光泽的表面会发生什么?在极限情况下(镜像),当且仅当我们能够看到此像素反射的光源时,像素才会亮起来:

对于漫射照明,我们计算了矢量 n 和 l 之间的(余弦)角度,现在我们感兴趣的是矢量 r(反射光方向)和 v(视图方向)之间的角度(余弦)。

第三个作业:

给定向量 n 和 l,找到向量 r。

答案是:

如果n和l归一化,则 r = 2n<n,l> - l

对于漫反射来说我们计算余弦作为光照强度。但是一个光滑的表面在一个方向反射比其他方向要多得多。好的,那么,如果我们使用 10 倍的余弦会怎么样呢?回想一下,如果这样做,所有比 1 小的数字会减少。那意味着,10 倍的余弦将将导致光束更小的半径。而百分之一的光束半径要小得多。这个倍数藏在特殊的纹理中(镜面纹理 specular mapping texture),告诉我们每一个点是不是光滑的。

struct Shader : public IShader {
    mat<2,3,float> varying_uv;  // same as above
    mat<4,4,float> uniform_M;   //  Projection*ModelView
    mat<4,4,float> uniform_MIT; // (Projection*ModelView).invert_transpose()

    virtual Vec4f 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
        return Viewport*Projection*ModelView*gl_Vertex; // transform it to screen coordinates
    }

    virtual bool fragment(Vec3f bar, TGAColor &color) {
        Vec2f 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.0f), 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(5 + c[i]*(diff + .6*spec), 255);
        return false;
    }
};

我认为除了系数之外,我不需要在上面的代码中评论任何内容。

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

周围部分为 5,漫反射部分为 1。0.6 作为镜面反射部分。你可以自己改变系数。不同的选择物体会有不同的样貌。通常是,画家去提供这些系数。

请记住,这些系数之和通常为 1。但是你知道,我喜欢创造光。

最后实现渲染器的代码

你可能感兴趣的:(为渲染器添加着色器( Shaders for the software renderer ))