非常感謝和推薦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三角形來提高我們的渲染器。