500行C++代碼實現軟件渲染器 - 2.三角形光柵化與背面剔除

填充三角形

大家好,這是我。

更精確來講,這是用接下來一兩個小時內我們將創建的程序渲染出來的我的臉的模型。上一次,我們繪製了三維模型的線框。這一次,我們將填充多邊形,或者三角形。事實上,OpenGL幾乎會對所有的多邊形進行三角化,所以這裏我們不需要去考慮更復雜的情況。

需要提醒的是,本系列教程設計的目的是幫助你自己獨立編程。當我說你可以在兩個小時內繪製一個類似上面的圖像,我並不是說閱讀代碼的時間,而是從零開始編碼的時間。我提供的代碼只是爲了給你一個參照。我不是一個優秀的程序員,極有可能你比我優秀,所有不要複製粘貼我的代碼。歡迎任何評論和諮詢。

老派方法:掃描線算法

因此,我們的任務是繪製二維三角形。對於積極性比較強的學生,這大概花費幾個小時,即便他們是比較差的程序員,上一次,我們看到了Bresenham的直線算法。今天我們的任務是繪製填充三角形。雖然有點搞笑,但是這個任務不是無意義的。我不知道爲什麼,但是我知道這是對的。我的大部分學生會在這個簡單的問題上掙扎。所以,初始代碼將是這樣的:

void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { 
    line(t0, t1, image, color); 
    line(t1, t2, image, color); 
    line(t2, t0, image, color); 
}

// ...

Vec2i t0[3] = {Vec2i(10, 70),   Vec2i(50, 160),  Vec2i(70, 80)}; 
Vec2i t1[3] = {Vec2i(180, 50),  Vec2i(150, 1),   Vec2i(70, 180)}; 
Vec2i t2[3] = {Vec2i(180, 150), Vec2i(120, 160), Vec2i(130, 180)}; 
triangle(t0[0], t0[1], t0[2], image, red); 
triangle(t1[0], t1[1], t1[2], image, white); 
triangle(t2[0], t2[1], t2[2], image, green);

同往常一樣,代碼提交到了Github。代碼很簡單。我提供了三個三角形供你調試使用。如果我們調用三角形函數內的line(),我們將得到三角形的外輪廓。那麼怎麼繪製一個填充的三角形呢?

一個比較好的繪製算法必須具有以下特點:

  • 它必須簡單且高效
  • 它必須是對稱的,也就是說繪製的結果不應該依賴於頂點傳入的順序
  • 如果兩個三角形具有相同的頂點,那麼考慮到光柵化的完整性,三角形之間不能有空隙
  • 我們可以增加更多的要求,但是我們先增加一個:通常使用掃描線算法
  1. 將三角形頂點根據y座標排序
  2. 同時光柵化三角形的左邊和右邊
  3. 在左右邊界點之間繪製一條水平線段

在這個時候,我的學生開始產生疑問了:哪個線段是左邊,哪個是右邊?三角形裏面有三個線段啊?通常,介紹完之後,我會給我的學生一個小時寫代碼。再次聲明,直接閱讀我的代碼與將我的代碼和自己的代碼比較相比是更沒有價值的。

【一個小時過去了】

怎樣繪製三角形呢?如果你有一個更好的額辦吧,我很樂意使用它。當我們假設三角形有三個頂點:t0、t1、t2,它們按照y座標遞增的順序排列。然後,邊界A由t0和t2相連,邊界B由t0和t1相連、t1和t2相連兩部分組成。

void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { 
    // sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!) 
    if (t0.y>t1.y) std::swap(t0, t1); 
    if (t0.y>t2.y) std::swap(t0, t2); 
    if (t1.y>t2.y) std::swap(t1, t2); 
    line(t0, t1, image, green); 
    line(t1, t2, image, green); 
    line(t2, t0, image, red); 
}

在這裏邊界A爲紅色,邊界B爲綠色。

不行的是,邊界B由兩部分組成,讓我們沿水平切斷,只繪製下半部分。

void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { 
    // sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!) 
    if (t0.y>t1.y) std::swap(t0, t1); 
    if (t0.y>t2.y) std::swap(t0, t2); 
    if (t1.y>t2.y) std::swap(t1, t2); 
    int total_height = t2.y-t0.y; 
    for (int y=t0.y; y<=t1.y; y++) { 
        int segment_height = t1.y-t0.y+1; 
        float alpha = (float)(y-t0.y)/total_height; 
        float beta  = (float)(y-t0.y)/segment_height; // be careful with divisions by zero 
        Vec2i A = t0 + (t2-t0)*alpha; 
        Vec2i B = t0 + (t1-t0)*beta; 
        image.set(A.x, y, red); 
        image.set(B.x, y, green); 
    } 
}

注意,線段並不是連續的。上一次,我們繪製的直線的時候,我們費了一番周折才繪製出連續的直線。在這裏,我們暫時不用對圖像進行翻轉(還記得xy左邊的交換嗎?)。爲什麼呢,因爲我們可以直接對三角形進行填充,這就是理由。我們我們沿水平方向將相應的點連接起來,缺口就消失了。

現在,讓我們繪製上半部分三角形,我們可以再增加一個循環體。

void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { 
    // sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!) 
    if (t0.y>t1.y) std::swap(t0, t1); 
    if (t0.y>t2.y) std::swap(t0, t2); 
    if (t1.y>t2.y) std::swap(t1, t2); 
    int total_height = t2.y-t0.y; 
    for (int y=t0.y; y<=t1.y; y++) { 
        int segment_height = t1.y-t0.y+1; 
        float alpha = (float)(y-t0.y)/total_height; 
        float beta  = (float)(y-t0.y)/segment_height; // be careful with divisions by zero 
        Vec2i A = t0 + (t2-t0)*alpha; 
        Vec2i B = t0 + (t1-t0)*beta; 
        if (A.x>B.x) std::swap(A, B); 
        for (int j=A.x; j<=B.x; j++) { 
            image.set(j, y, color); // attention, due to int casts t0.y+i != A.y 
        } 
    } 
    for (int y=t1.y; y<=t2.y; y++) { 
        int segment_height =  t2.y-t1.y+1; 
        float alpha = (float)(y-t0.y)/total_height; 
        float beta  = (float)(y-t1.y)/segment_height; // be careful with divisions by zero 
        Vec2i A = t0 + (t2-t0)*alpha; 
        Vec2i B = t1 + (t2-t1)*beta; 
        if (A.x>B.x) std::swap(A, B); 
        for (int j=A.x; j<=B.x; j++) { 
            image.set(j, y, color); // attention, due to int casts t0.y+i != A.y 
        } 
    } 
}

這個效果可能就夠了,但是我不喜歡重複的代碼。雖然這會讓代碼可讀性變差,但是也會爲修改和維護提供更多便利。

void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) { 
    if (t0.y==t1.y && t0.y==t2.y) return; // I dont care about degenerate triangles 
    // sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!) 
    if (t0.y>t1.y) std::swap(t0, t1); 
    if (t0.y>t2.y) std::swap(t0, t2); 
    if (t1.y>t2.y) std::swap(t1, t2); 
    int total_height = t2.y-t0.y; 
    for (int i=0; i<total_height; i++) { 
        bool second_half = i>t1.y-t0.y || t1.y==t0.y; 
        int segment_height = second_half ? t2.y-t1.y : t1.y-t0.y; 
        float alpha = (float)i/total_height; 
        float beta  = (float)(i-(second_half ? t1.y-t0.y : 0))/segment_height; // be careful: with above conditions no division by zero here 
        Vec2i A =               t0 + (t2-t0)*alpha; 
        Vec2i B = second_half ? t1 + (t2-t1)*beta : t0 + (t1-t0)*beta; 
        if (A.x>B.x) std::swap(A, B); 
        for (int j=A.x; j<=B.x; j++) { 
            image.set(j, t0.y+i, color); // attention, due to int casts t0.y+i != A.y 
        } 
    } 
}

這裏是繪製二維三角形的代碼。

我所使用的方法

掃描線算法雖然不復雜,但是代碼還是有點亂。同時,它也是爲單線程編程所設計的老派方法。讓我們先看一下以下僞代碼:

triangle(vec2 points[3]) { 
    vec2 bbox[2] = find_bounding_box(points); 
    for (each pixel in the bounding box) { 
        if (inside(points, pixel)) { 
            put_pixel(pixel); 
        } 
    } 
}

你喜歡這個代碼嗎?反正我很喜歡。要找到包圍盒很容易,要檢查一個點是否在二維三角形內部或者任意凸多邊形內部也是沒問題的。

題外話:如果我要實現一個算法來判斷點是否在多邊形內,並且這個程序在飛機上運行,那麼我絕不會坐這個飛機。事實證明,要可靠的解決這個問題是非常困難的。但是在這裏,我們只是繪製要素,就沒關係了。

關於這段僞代碼還有一點我比較喜歡的是,編程新手很樂意接受它,而有經驗的程序員會嗆道:“哪個蠢貨寫的代碼”,但是圖形學專家會聳聳肩膀說:“實際上真實世界裏程序就是這麼工作的”。成千上萬個線程中的大規模並行計算(我在這裏談論的是常規消費級計算機)改變了思維方式。

好的,讓我們開始:首先,我們需要知道什麼是重心座標系。給定一個二維三角形ABC和一個點P,所有的點都是以笛卡爾座標系(x,y)給出。我們的目標是找到點P相對於三角形ABC的重心座標。這意味着,我們需要找到三個數字(1  -  u  -  v,u,v),使P滿足如下:

P=(1-u-v)A+uB+vC

咋一看有些害怕,實際卻很簡單。想象以下,我們把三個權重(1  -  u  -  v,u,v)相應的賦給點A、B和C。那麼系統的中心剛好在P點。或者我們可以說:

P=A+u\vec{AB}+v\vec{AC}

我們有向量\vec{AB}\vec{AC}\vec{AP},我們要找到兩個實數u和v,滿足:

u\vec{AB}+v\vec{AC}+\vec{PA}=\vec{0}

這是一個簡單的向量方程,或者說是兩個二元一次方程。

\left\{\begin{matrix} u\vec{AB_{x}}+v\vec{AC_{x}}+\vec{PA_{x}}=0\\ u\vec{AB_{y}}+v\vec{AC_{y}}+\vec{PA_{y}}=0\\ \end{matrix}\right.

我很懶,不想以學者的方式解線性方程。讓我們用矩陣的方式重寫一下:

\left\{\begin{matrix} [\begin{matrix} u & v& 1& \end{matrix}] [\begin{matrix} \vec{AB_{x}}\\ \vec{AC_{x}}\\ \vec{PA_{x}}\\ \end{matrix}] =0\\ [\begin{matrix} u & v& 1& \end{matrix}] [\begin{matrix} \vec{AB_{y}}\\ \vec{AC_{y}}\\ \vec{PA_{y}}\\ \end{matrix}] =0\\ \end{matrix}\right.

也就是說,我們要找到向量(u,v,1) 同時與向量 (ABx,ACx,PAx)、(ABy,ACy,PAy)正交。我希望你明白我將要做什麼。先給一個提示:要找到平面中兩條直線的交點(這正是我們在這裏所做的),計算一個交叉乘積就足夠了。 順便說一下,你可以自己測試一下:如何找到通過兩個給定點的直線方程。

因此,讓我們來編寫新的光柵化程序。我們遍歷三角形邊界框的所有像素。對於每一個像素,計算其重心座標。如果它至少有一個負的分量,那麼像素就在三角形之外。直接看程序可能更清晰:

#include <vector> 
#include <iostream> 
#include "geometry.h"
#include "tgaimage.h" 
 
const int width  = 200; 
const int height = 200; 
 
Vec3f barycentric(Vec2i *pts, Vec2i P) { 
    Vec3f u = cross(Vec3f(pts[2][0]-pts[0][0], pts[1][0]-pts[0][0], pts[0][0]-P[0]), Vec3f(pts[2][1]-pts[0][1], pts[1][1]-pts[0][1], pts[0][1]-P[1]));
    /* `pts` and `P` has integer value as coordinates
       so `abs(u[2])` < 1 means `u[2]` is 0, that means
       triangle is degenerate, in this case return something with negative coordinates */
    if (std::abs(u[2])<1) return Vec3f(-1,1,1);
    return Vec3f(1.f-(u.x+u.y)/u.z, u.y/u.z, u.x/u.z); 
} 
 
void triangle(Vec2i *pts, TGAImage &image, TGAColor color) { 
    Vec2i bboxmin(image.get_width()-1,  image.get_height()-1); 
    Vec2i bboxmax(0, 0); 
    Vec2i clamp(image.get_width()-1, image.get_height()-1); 
    for (int i=0; i<3; i++) { 
        for (int j=0; j<2; j++) { 
            bboxmin[j] = std::max(0,        std::min(bboxmin[j], pts[i][j])); 
            bboxmax[j] = std::min(clamp[j], std::max(bboxmax[j], pts[i][j])); 
        } 
    } 
    Vec2i P; 
    for (P.x=bboxmin.x; P.x<=bboxmax.x; P.x++) { 
        for (P.y=bboxmin.y; P.y<=bboxmax.y; P.y++) { 
            Vec3f bc_screen  = barycentric(pts, P); 
            if (bc_screen.x<0 || bc_screen.y<0 || bc_screen.z<0) continue; 
            image.set(P.x, P.y, color); 
        } 
    } 
} 
 
int main(int argc, char** argv) { 
    TGAImage frame(200, 200, TGAImage::RGB); 
    Vec2i pts[3] = {Vec2i(10,10), Vec2i(100, 30), Vec2i(190, 160)}; 
    triangle(pts, frame, TGAColor(255, 0, 0)); 
    frame.flip_vertically(); // to place the origin in the bottom left corner of the image 
    frame.write_tga_file("framebuffer.tga");
    return 0; 
}

barycentric()方法是用來計算點P在給定三角形的重心座標,我們已經看到了細節。現在讓我們來看triangle()函數是如何工作的。首先,它計算一個邊界框,它由左下角和右上角描述。爲了找到這兩個角,我們遍歷三角形的頂點並選擇最小/最大座標。 我還添加了一個帶屏幕矩形的邊界框裁剪,以節省繪製屏幕外三角形的CPU時間。 恭喜你,你知道如何繪製一個三角形!

平面着色渲染

我們已經知道如何繪製帶有空三角形的模型。現在讓我們給三角形填充一個隨機顏色。這將有助於我們瞭解填充三角形的編碼情況。 這是代碼:

for (int i=0; i<model->nfaces(); i++) { 
    std::vector<int> face = model->face(i); 
    Vec2i screen_coords[3]; 
    for (int j=0; j<3; j++) { 
        Vec3f world_coords = model->vert(face[j]); 
        screen_coords[j] = Vec2i((world_coords.x+1.)*width/2., (world_coords.y+1.)*height/2.); 
    } 
    triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(rand()%255, rand()%255, rand()%255, 255)); 
}

很簡單,就像以前一樣,我們遍歷所有的三角形,將世界座標轉換到屏幕座標,然後繪製它。我將在接下來的文章中詳細描述不同的座標系。當前渲染的圖片看起來是這樣的:

爲了避免這些小丑般的顏色,我們加入一些光照。首先需要明確:“在光照強度一樣的地方,多邊形與光線方向正交的時候被照得最亮"。讓我們對比一下:

如果多邊形與光照方向平行,那麼光照亮度爲零。換句話說:照明強度等於光矢量和給定三角形的法線的標量積。 三角形的法線可以簡單地通過兩邊的叉積計算得到。

旁註:在本課程中,我們對顏色進行線性計算。 然而(128,128,128)顏色並不是(255,255,255)亮度的一半。 我們將忽略伽馬校正並容忍我們顏色亮度的不正確。

for (int i=0; i<model->nfaces(); i++) { 
    std::vector<int> face = model->face(i); 
    Vec2i screen_coords[3]; 
    Vec3f world_coords[3]; 
    for (int j=0; j<3; j++) { 
        Vec3f v = model->vert(face[j]); 
        screen_coords[j] = Vec2i((v.x+1.)*width/2., (v.y+1.)*height/2.); 
        world_coords[j]  = v; 
    } 
    Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]); 
    n.normalize(); 
    float intensity = n*light_dir; 
    if (intensity>0) { 
        triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255)); 
    } 
}

但點積可能是負數。 這是什麼意思? 這意味着光線來自多邊形後面。 如果場景建模得很好(通常是這種情況),我們可以簡單地丟棄這個三角形。 這允許我們快速刪除一些不可見的三角形。 它被稱爲背面剔除。

注意,嘴的內腔被繪製在嘴脣的頂部。 這是因爲我們對不可見三角形的剪裁手段還比較差:它僅適用於凸形。 下次當我們使用z緩衝區編碼之後,我們就會避免這一現象。

這是渲染的當前版本。 你覺得我臉上的形象更加細緻嗎? 好吧,因爲我作弊了一下:我的臉部模型有25萬個三角形,而這個人造頭模型大約有一千個。 但我的臉確實是用上面的代碼渲染的。 我保證,在下面的文章中我們將爲此圖片添加更多細節。

 

感謝原作者Dmitry V. Sokolov的授權,原文鏈接:https://github.com/ssloy/tinyrenderer/wiki/Lesson-2:-Triangle-rasterization-and-back-face-culling

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