第一版
這一課的目標是繪製線框。爲了實現這個目標,我們需要先學會繪製線段。我們可以直接閱讀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