500行C++代碼實現軟件渲染器 - 1.Bresenham直線繪製算法

第一版

這一課的目標是繪製線框。爲了實現這個目標,我們需要先學會繪製線段。我們可以直接閱讀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*(1.-t) + x1*t; 
        int y = y0*(1.-t) + y1*t; 
        image.set(x, y, color); 
    } 
}

直線繪製效果如下,完整代碼在此。

第二版

先不論效率,第一版代碼的問題還在於常量的選擇,代碼中該常量等於0.01。如果我們把常量設置爲0.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); 
        } 
    } 
}

第四版-控制時間

提醒:編譯器的優化(g++ -O3)往往比你優化代碼的效果更好。這一節的內容的存在是因爲歷史原因。

第三版的代碼效果很好,複雜度也正是實現最終渲染器所想要的。雖然它的效率顯然是不高的,但是代碼簡潔、可讀性強。同時注意,代碼中也沒有斷言和邊界檢查,這很糟糕。在這個系列文章中,我不會重載這些代碼,因爲這些代碼正被廣泛閱讀。同時,我會對代碼檢查進行系統地提醒。

因此,隨然第三版代碼能很好運行,但是我們仍然可以優化它。優化是一件危險的事情。我們需要清楚代碼運行的平臺。針對圖形卡進行優化和針對CPU進行優化是完全不同的兩件事情。在開展優化之前,我們必須對代碼進行分析。並試圖猜測,哪些操作是對資源消耗比較敏感的。

爲了測試,我將之前的三條線段繪製了一百萬次。我的CPU是Intel® Core(TM) 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%的時間花費在調用line()方法上了。那麼這個方法就是我們需要優化的。

第四版-繼續

我們注意到for循環中的除運算每次都是一樣的,我們把它移到循環體外面。error變量記錄了從當前(x,y)座標到最佳線段的距離。每一次error大於一個像素,我們把y增加一,同時吧error減去一。

源代碼在此:

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

這是gprof的輸出結果:

%   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) 

第五版-最終版

爲什麼我們一定要使用浮點數呢?唯一的原因是我們需要使用1除以dx並在循環體內與0.5進行比較。我們可以避免使用浮點數,把error變量替換爲另外一個。我們稱之爲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) 

現在,我們可以將不需要的副本刪掉了,通過引用傳遞顏色進行調用就行了。最終版本代碼裏面,沒有一個乘法和除法。最終執行時間從2.95秒下降到0.64秒。

線框渲染

現在我們可以準備完成線框的渲染了。你可以在這裏查看源代碼和測試模型。我使用了obj格式文件存儲模型信息。我們的渲染器需要的信息是從頂點數組中讀取出來的,格式如下:

v 0.608654 -0.568839 -0.416318

每一個頂點xyz座標佔一行,三角面的頂點信息如下:

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

每個空格之後數字表示頂點數組中讀取頂點的序號。這就表示,頂點序號爲1193、1180、1179的頂點組成一個三角形。源代碼中model.cpp包含了一個簡單的轉換器。通過以下main.cpp中的循環,我們的線框模型就能繪製出來了。

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

下一次,我們將繪製二維的三角形以提升我們的渲染器效果。

感謝原作者Dmitry V. Sokolov的授權,原文鏈接:https://github.com/ssloy/tinyrenderer/wiki/Lesson-1:-Bresenham%E2%80%99s-Line-Drawing-Algorithm

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