[從零構建光柵渲染器] 1.Bresenham 畫線算法

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

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

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

本章運行結果

第一次嘗試

第一課的目標是渲染一個線形網格。爲了畫它,我們應該先學習如何去繪製線段。我們可以簡單閱讀以下Bresenham畫線算法,但是讓我們自己來寫代碼。應該怎麼寫一個從點(x0, y0)到點(x1, y1)的線段呢?
可以這樣:

void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
    for (float t=0.; t<1.; t+=.01) { 
        int x = x0 + (x1-x0)*t; 
        int y = y0 + (y1-y0)*t; 
        image.set(x, y, color); 
    } 
}

在這裏插入圖片描述

代碼在這裏可以找到here

第二次嘗試

上面的代碼問題是變量的選擇(當然還有效率不高的問題)。我上面是讓等於.01,如果改成.1,線段會成這樣:

在這裏插入圖片描述

我們可以輕鬆找到其中重要的地方:僅僅是繪製像素的數目不同(第一次繪製的像素數目多,改變常量爲.1後,繪製的像素變少),一種簡單的方法如下:(其實不好的,錯誤的)

void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
    for (int x=x0; x<=x1; x++) { 
        float t = (x-x0)/(float)(x1-x0); 
        int y = y0*(1.-t) + y1*t; 
        image.set(x, y, color); 
    } 
}

警告!我的學生出現整數除法的錯誤,像(x - x0) / (x1 - x0)。因此,我們使用下面的代碼會繪製出這樣的直線:

line(13, 20, 80, 40, image, white); 
line(20, 13, 40, 80, image, red); 
line(80, 40, 13, 20, image, red);

在這裏插入圖片描述

一條直線是正確的,第二條直線有很多洞,根本看不到第三條直線。注意:在這個代碼中第一條和第三條直線的位置相同,顏色不相同,方向不相同。(起點和終點進行翻轉)。我們看到了白色的線,它繪製的很好。我希望把這條白色的線轉換成紅色的,但是沒有完成。對稱測試:繪製線段時不應取決於頂點的順序:(A, B)和(B, A)線段應該是一樣的。

第三次嘗試

我們通過交換點來修復丟失的紅線,使x0始終低於x1。

第二次嘗試中,紅色線段的高度大於寬度,因此有很多孔,不連續的地方。我的學生建議使用下面代碼來修復:

if (dx>dy) {for (int x)} else {for (int y)}

天啊!

void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
    bool steep = false; 
    if (std::abs(x0-x1)<std::abs(y0-y1)) { // if the line is steep, we transpose the image 
        std::swap(x0, y0); 
        std::swap(x1, y1); 
        steep = true; 
    } 
    if (x0>x1) { // make it left−to−right 
        std::swap(x0, x1); 
        std::swap(y0, y1); 
    } 
    for (int x=x0; x<=x1; x++) { 
        float t = (x-x0)/(float)(x1-x0); 
        int y = y0*(1.-t) + y1*t; 
        if (steep) { 
            image.set(y, x, color); // if transposed, de−transpose 
        } else { 
            image.set(x, y, color); 
        } 
    } 
}

在這裏插入圖片描述

第四次嘗試

警告:編譯器的優化器在創建高效的代碼方面比你和我都好。我們應該注意,每個除法都有相同的除數。此部分是出於歷史文化的原因。

這個代碼工作的很好。這正是我在最終版本或渲染器中看到的那種複雜性。但其確實是非常低效的(因爲有很多除法),但是它代碼很短,可讀性比較好。注意,代碼沒有使用斷言,也沒有檢查是否超出邊界,這非常不好。在這篇文章中,我要經常重複使用這塊代碼,我係統的檢查了其必要性。

雖然之前的代碼工作的很好,但是我們還可以優化。優化是一個危險的事情。我們應該清楚代碼所運行的平臺。針對GPU和CPU的優化是兩種完全不同的事情。在優化之前,我們需要分析代碼。考慮一下,哪個操作是比較消耗資源的。

測試:我執行了1000000次繪製之前的三條直線。我的CPU是Intel® Core™ i5-3450 CPU @ 3.10GHz. 對於每個像素,代碼都調用了TGAColor的複製構造函數,總共像素數目=1000000 * 3(線段) * 50(每條線段像素數目)。我們應該從哪裏開始優化?分析器會告訴我們。

我使用命令g++ -ggdb -g -pg -O0完成編譯,然後運行gprof:

%   cumulative   self              self     total 
 time   seconds   seconds    calls  ms/call  ms/call  name 
 69.16      2.95     2.95  3000000     0.00     0.00  line(int, int, int, int, TGAImage&, TGAColor) 
 19.46      3.78     0.83 204000000     0.00     0.00  TGAImage::set(int, int, TGAColor) 
  8.91      4.16     0.38 207000000     0.00     0.00  TGAColor::TGAColor(TGAColor const&) 
  1.64      4.23     0.07        2    35.04    35.04  TGAColor::TGAColor(unsigned char, unsigned char, unsigned char, unsigned char) 
  0.94      4.27     0.04                             TGAImage::get(int, int)

複製顏色話費了10%的時間,70%的時間花費在了繪製直線上!這正是咱們需要優化的地方。

繼續第五次嘗試

我們應該注意到了每個除法都有相同的除數。讓我們把它從循環裏面拿出來。誤差變量給出我們當前點(x, y)到直線的距離,每次誤差大於一個像素的時候,我們將y增加或減小1,當然誤差也需要增加減小1.

代碼在這裏here.

void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
    bool steep = false; 
    if (std::abs(x0-x1)<std::abs(y0-y1)) { 
        std::swap(x0, y0); 
        std::swap(x1, y1); 
        steep = true; 
    } 
    if (x0>x1) { 
        std::swap(x0, x1); 
        std::swap(y0, y1); 
    } 
    int dx = x1-x0; 
    int dy = y1-y0; 
    float derror = std::abs(dy/float(dx)); 
    float error = 0; 
    int y = y0; 
    for (int x=x0; x<=x1; x++) { 
        if (steep) { 
            image.set(y, x, color); 
        } else { 
            image.set(x, y, color); 
        } 
        error += derror; 
        if (error>.5) { 
            y += (y1>y0?1:-1); 
            error -= 1.; 
        } 
    } 
} 

翻譯作者內容:這裏就是Bresenham畫線算法了,如果您看不懂代碼,請及時看一下《計算機圖形學》Bresenham 畫線算法的章節。

調試信息在這:

%   cumulative   self              self     total 
 time   seconds   seconds    calls  ms/call  ms/call  name 
 38.79      0.93     0.93  3000000     0.00     0.00  line(int, int, int, int, TGAImage&, TGAColor) 
 37.54      1.83     0.90 204000000     0.00     0.00  TGAImage::set(int, int, TGAColor) 
 19.60      2.30     0.47 204000000     0.00     0.00  TGAColor::TGAColor(int, int) 
  2.09      2.35     0.05        2    25.03    25.03  TGAColor::TGAColor(unsigned char, unsigned char, unsigned char, unsigned char) 
  1.25      2.38     0.03                             TGAImage::get(int, int) 

最後的嘗試

爲什麼我們需要浮點數頂點呢?唯一的原因就是在循環體裏用dx除以1和.5進行比較。我們可以通過換一個誤差變量來去掉浮點頂點。讓我們回顧error2,假設它等於error * dx * 2,這裏是代碼:

void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
    bool steep = false; 
    if (std::abs(x0-x1)<std::abs(y0-y1)) { 
        std::swap(x0, y0); 
        std::swap(x1, y1); 
        steep = true; 
    } 
    if (x0>x1) { 
        std::swap(x0, x1); 
        std::swap(y0, y1); 
    } 
    int dx = x1-x0; 
    int dy = y1-y0; 
    int derror2 = std::abs(dy)*2; 
    int error2 = 0; 
    int y = y0; 
    for (int x=x0; x<=x1; x++) { 
        if (steep) { 
            image.set(y, x, color); 
        } else { 
            image.set(x, y, color); 
        } 
        error2 += derror2; 
        if (error2 > dx) { 
            y += (y1>y0?1:-1); 
            error2 -= dx*2; 
        } 
    } 
} 
%   cumulative   self              self     total 
 time   seconds   seconds    calls  ms/call  ms/call  name 
 42.77      0.91     0.91 204000000     0.00     0.00  TGAImage::set(int, int, TGAColor) 
 30.08      1.55     0.64  3000000     0.00     0.00  line(int, int, int, int, TGAImage&, TGAColor) 
 21.62      2.01     0.46 204000000     0.00     0.00  TGAColor::TGAColor(int, int) 
  1.88      2.05     0.04        2    20.02    20.02  TGAColor::TGAColor(unsigned char, unsigned char, unsigned char, unsigned char)

現在,只需要通過引用傳遞顏色就可以刪除不必要的副本(或者使用編譯 flag -O3)。注意代碼中的乘法和除法,執行時間從2.95縮短了0.64。

我建議查看這個this issue。優化是棘手的!

線框渲染

我們準備創建一個線框渲染器。在這裏你可以找到代碼code and the test model here。我使用wavefront obj格式文件來保存模型。我們需要從文件中讀取到頂點數組,格式如下:

v 0.608654 -0.568839 -0.416318

x,y,z是座標,每個面對應三個頂點,格式如下。

f 1193/1240/1193 1180/1227/1180 1179/1226/1179

翻譯作者內容:以1193/1240/1193爲例,1193對應頂點索引,1240是紋理座標uv索引,1193是法向量索引,在後面會提到。也就是說這個面的三個頂點是第1193,1180,1179所對應的x,y,z。

在本篇文章,我們只需要讀取空格後的第一個數字,也就是頂點座標,紋理和法向量我們現在不關心。因此,1193, 1180 和 1179頂點組成一個三角形。注意obj文件的索引從1開始,也就意味着你需要分別從1192,1179和1178找到。model.cpp解析.obj文件。在主函數中寫下如下代碼,我們的線渲染就好了。

for (int i=0; i<model->nfaces(); i++) { 
    std::vector<int> face = model->face(i); 
    for (int j=0; j<3; j++) { 
        Vec3f v0 = model->vert(face[j]); 
        Vec3f v1 = model->vert(face[(j+1)%3]); 
        int x0 = (v0.x+1.)*width/2.; 
        int y0 = (v0.y+1.)*height/2.; 
        int x1 = (v1.x+1.)*width/2.; 
        int y1 = (v1.y+1.)*height/2.; 
        line(x0, y0, x1, y1, image, white); 
    } 
}

下一章我們將會繪製一個2D三角形來提高我們的渲染器。

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