[從零構建光柵渲染器] 6. 頂點和片元着色器的工作原理

[從零構建光柵渲染器] 6. 頂點和片元着色器的工作原理

非常感謝和推薦Sokolov的教程,Sokolov使用500行C++代碼實現一個光柵渲染器。教程學習過程非常平滑,從畫點、線和三角形開始教學,在逐步深入三維變換,投影,再到頂點着色器,片段着色器等等。教程地址:https://github.com/ssloy/tinyrenderer。Sokolov的教程爲英文,我翻譯了其文章。

在學習過程中,有些內容可能您可能雲裏霧裏,這時就需要查閱《計算機圖形學》的書籍了,這裏面的算法和公式可以幫助您理解代碼。

作者:憨豆酒(YinDou),聯繫我[email protected],熟悉圖形學,圖像處理領域,本章的源代碼可在此倉庫中找到https://github.com/douysu/person-summary:如果對您有幫助,還請給一個star,如果大家發現錯誤以及不合理之處,還希望多多指出。

我的知乎

我的Github

我的博客

本章運行結果

圖片

開始

請記住,我的代碼只是幫你進行參考,不要用我的代碼,寫你自己的代碼。我是個糟糕的程序員。請你做最瘋狂的着色器,並把圖片發給我,我會把它們貼在這裏。

有趣的時間,首先讓我們檢查一下我們現在的代碼。source code

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

總共525行,正是我們想要的。請注意,只有our_gl.*和main.cpp兩個文件負責實際渲染,總共168行。

圖片

重構代碼

main.cpp中的代碼太多了,讓我們分割成兩部分:

  • our_gl.h+cpp——這部分開發者接觸不到,說白了是OpenGL的library。
  • 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);

翻譯作者內容:從上面的代碼可以看到vertex()方法和fragment()方法,這裏就是我們常用的頂點着色器和片元着色器,從這兩個函數中,我們可以明白着色器的工作原理。

文件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 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; i<model->nfaces(); 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結構,所以我們跳過它。然後是main()函數的實際內容:

  • 解析.obj文件
  • 初始化ModelView、Projection和Viewport矩陣(記得這些矩陣的實際實例都在our_gl模塊中)。
  • 通過模型中的所有三角形進行迭代,並對每個三角形進行柵格化。

最後一步是最有意思的。外循環迭代所有的三角形,內循環迭代當前三角形的所有頂點,併爲每個頂點調用一個頂點着色器。(這就是頂點着色器的功能)

頂點着色器的主要目標是轉換頂點的座標。次要目標是爲片段着色器準備數據。

那之後呢?我們稱之爲光柵化例程。我們不知道柵格化器內部會發生什麼(好吧,我們知道,因爲我們編寫了程序!),但有一個例外。我們知道光柵化器會對每個像素調用我們的例程,即片段着色器。同樣,對於三角形內的每個像素,光柵器會調用我們自己的回調,即片段着色器。

片段着色器的主要目標–是確定當前像素的顏色。次要目標–我們可以通過返回true來丟棄當前像素。

OpenGL 2的渲染管道可以用以下方式表示(事實上,對於較新的版本也差不多)。

圖片

由於我的課程時間有限,所以我只限於OpenGL 2流水線,因此只限於片段和頂點着色器。在較新版本的OpenGL中,還有其他的着色器,比如說幾何着色器,計算着色器。

好了,在上圖中,所有我們不能觸及的階段都用藍色顯示,而我們的回調則用橙色顯示。其實,我們的main()函數–就是原始處理例程。它調用的是頂點着色器。我們在這裏並沒有進行基元裝配,因爲我們只畫最基本的三角形(在我們的代碼中,它與基元處理合併在一起)。 triangle()函數–是光柵化器,對於三角形內的每一個點,它調用片段着色器,然後執行深度檢查(z-buffer)之類的。

好了,你知道了什麼是着色器了並且可以寫自己的着色器了。

我實現的 Gouraud着色的着色器

圖片

我們來看看我上面列出的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語言中的一個保留關鍵字,我用variable_intensity作爲名稱來表示對應關係(我們在第9課中會講到GLSL)。在 varying變量中,我們在三角形內部存儲要插值的數據,片段着色器得到插值(針對當前像素)。

讓我們重新列舉一下片元着色器:

  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
    }

這個例程是針對我們繪製的三角形內的每一個像素點來調用的;作爲輸入,它接收到的是雙心座標,用於variing_數據的插值。因此,插值後的強度可以計算爲variing_intensity[0]*bar[0]+variing_intensity[1]*bar[1]+variing_intensity[2]bar[2],或者簡單地計算爲兩個向量之間的點積:variing_intensitybar。當然,在真正的GLSL中,片段着色器接收的是現成的插值。

注意,片元着色器返回一個bool值。如果我們看一下rasterizer內部(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);
            }

Fragment 着色器可以丟棄當前像素的繪製,然後光柵化器簡單地跳過它。如果我們想創建二進制蒙版或其他什麼東西,它就很方便了(請查看第9課的一個非常酷的丟棄像素的例子)。

當然,光柵器無法想象你可以編程的所有奇怪的東西,因此它不能和你的着色器一起預編譯。這裏我們用抽象的類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;
    }

Gourad着色簡單的修改,把強度改成6個階段:如下

圖片

紋理

我們先跳過Phong着色Phong shading,但是先看一下這個文章。還記得我給你佈置的紋理作業嗎?我們必須要插補紫外線座標。所以,我創建了一個2x3矩陣。2行代表u和v,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
    }
};

這裏是結果:

圖片

法線貼圖

好了,現在我們有了紋理座標。我們可以在紋理圖像中存儲什麼?其實,幾乎什麼都可以。它可以是顏色、方向、溫度等等。讓我們加載這個紋理。

圖片

如果我們將RGB值解釋爲xyz方向,那麼這個圖像就可以爲我們的渲染的每個像素提供法線向量,而不僅僅是像之前一樣爲每個頂點提供法線向量。

翻譯作者內容:上面是基於頂點計算的法向量,三角形裏面的顏色是基於差值得到的,所以不太好,使用法向量紋理是每個像素的法向量,計算顏色比較好

順便說一下,把這張圖和另一張相比,它給出的信息完全一樣,但在另一維度中。

圖片

其中一個圖像給出了全局(笛卡爾)座標系中的法向量,另一個圖像給出了Darboux框架(所謂的切線空間)中的法向量。在Darboux座標系中,z向量是物體的法線,x–主曲率方向,y–它們的叉積。

維基內容:

圖片

練習1:你能告訴我哪個圖像是在Darboux座標系中,哪個是全局座標系中嗎

練習2:你能說出哪種表現形式比較好,如果能,爲什麼?

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;                 // 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++) {
        Vec4f 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 和它的反轉置來變換法向量(參考第 5 課的結尾)。所以,光照強度的計算和以前一樣,只有一個例外:我們不是插值法向量,而是從法線貼圖紋理中獲取信息(不要忘記變換光向量和法向量)。

圖片

鏡面貼圖

好了,讓我們繼續開始吧。所有的計算機圖形學都是騙人的藝術。爲了(廉價地)欺騙眼睛,我們使用Phong的近似照明模型。Phong提出將最終的光照看成(加權)三種光照強度的(加權)之和:環境光照(每個場景的常數)、漫射光照(我們計算到此刻的那個)和鏡面光照。

看一下下面的圖片,不言而喻。

圖片

我們將漫反射光的計算方法爲法線矢量與光的方向矢量之間的餘弦角。我的意思是,這假設光在各個方向上都是均勻反射的。那麼對於有光澤的表面會怎樣呢?在極限情況下(鏡面),如果並且只有當我們能看到這個像素反射的光源時,這個像素纔會被照亮。

翻譯作者內容:光照計算可以OpenGL的書籍或者LearnOpenGL。

圖片

對於漫射光,我們計算了向量n和l之間的(餘弦角),現在我們更加關注的是向量r(反射光方向)和v(視線方向)之間的(餘弦)角。

練習3:給定向量n和l,得到向量r

答案:如果n和l都是規則化的, r = 2n<n,l> - l

對於漫射光,我們計算光強爲餘弦。但是,一個有光澤的表面在一個方向上的反射率要比其他方向上的反射率高得多! 那麼,如果我們取餘弦的第10次冪會怎樣呢?回想一下,所有小於1的數字在我們應用這個冪的時候都會減小。這意味着,餘弦的第10次冪的餘弦會使反射光束的半徑變小。而第100次冪就會得到更小的光束半徑。這個功率被存儲在一個特殊的紋理(鏡面映射紋理)中,它告訴每個點是否有光澤。

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<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,漫射分量取1,鏡面分量取0.6。選擇什麼樣的係數–是你的選擇。不同的選擇會給物體帶來不同的外觀。通常是由美術來決定的。

請注意,通常情況下,共價之和必須等於1,但你知道。我喜歡創造光。

圖片

圖片

結論

我們知道如何渲染一個好的場景,但是還不夠真實,需要加上陰影,下節課會介紹。享受。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章