【十天自制軟渲染器】DAY 03:畫一個三角形(向量叉乘算法 & 重心坐標算法)

前面兩天畫了點和線恃泪,今天我們來畫一個最簡單也是最強大的面——三角形

本文主要講解三角形繪制算法的推導和思路(只涉及到一點點的向量知識)犀斋,最后會給出代碼實現悟泵,大家放心的看下去就好。

<br />

本文源碼 ??:toyRenderer-day3-draw-triangle

1.如何畫一個三角形闪水?

在正式開始這一小節(jié)前糕非,我們先想一下如何利用上一節(jié)的畫線算法繪制一個實心的三角形。

假設現在平面內有三個不共線的點組成一個三角形球榆,我們可以利用上一節(jié)的直線算法輕易的連接三角形的三條邊朽肥,這時候我們會生成一個空心的封閉的三角形持钉。

那么這時候問題就轉換為衡招,如何把這個空心的三角形變?yōu)橐粋€實心的三角形?

我想大家這時候已經有思路了每强,就是一行一行地掃描像素始腾,把兩個邊界點之間的像素全部涂滿上色就可以了。

day03_scanLineDrawTriangle

這個方法肯定是可以的空执,但是實現不是很優(yōu)雅浪箭,也不是業(yè)內的主流實現方式。因為基于行掃描的算法不是本文的重點辨绊,所以詳細的推導和代碼實現就不提供了奶栖,感興趣的同學可以自己嘗試實現一下。

2.利用向量叉乘畫三角形

開始本節(jié)前先簡單復習一下向量叉乘的幾何意義门坷。

2.1 數學推導

在三維空間中宣鄙,兩個三維向量 \mathbf {a}\mathbf 做叉乘默蚌,會得到一個和已知兩個向量垂直的新向量 \mathbf {a} \times \mathbf 冻晤

既然叉乘產生的是一個新向量绸吸,那么它肯定有個方向鼻弧,我們一般用右手定則來判斷:將右手食指指向 \mathbf {a} 的方向设江、中指指向 \mathbf 的方向温数,則此時拇指的方向即為 \mathbf {a} \times \mathbf 的方向蜻势。

右手定則

綜上所述撑刺,我們可以對向量叉乘做一個嚴謹的定義:

{\displaystyle \mathbf {a} \times \mathbf  =\|\mathbf {a} \|\|\mathbf 握玛 \|\sin(\theta )\ \mathbf {n} }

其中 \theta 表示 \mathbf {a}\mathbf 够傍 在它們所定義的平面上的夾角(0^{\circ }\leq \theta \leq 180^{\circ})。{\displaystyle \|\mathbf {a} \|}\displaystyle \|\mathbf 挠铲 \| 是向量 \mathbf {a}\mathbf 冕屯 的模長,而 \mathbf{n} 則是一個與 \mathbf {a} 拂苹、\mathbf 安聘 所構成的平面垂直的單位向量,方向由右手定則決定瓢棒。

<br />

有上面的理論浴韭,我們就可以判斷兩個向量的相對位置:

  • \mathbf {a} 向量叉乘 \mathbf 向量脯宿,如果值為念颈,則表示 \mathbf 向量在 \mathbf {a} 向量
  • \mathbf {a} 向量叉乘 \mathbf 连霉 向量榴芳,如果值為,則表示 \mathbf 跺撼 向量在 \mathbf {a} 向量
  • \mathbf {a} 向量叉乘 \mathbf 窟感 向量,如果值為歉井,則表示 \mathbf 肌括 向量與 \mathbf {a} 向量共線

<br />

會判斷兩條線的相對位置了,我們可以做個理論遷移酣难,利用向量叉乘判斷點和三角形的位置關系谍夭。

例如下面這里例子,對于三角形 \Delta ABC 來說憨募,把三條邊看作 \overrightarrow{A B}紧索、 \overrightarrow{B C}\overrightarrow{C A} 三條首尾相連的向量菜谣,平面內有一個點 P珠漂,我們通過向量叉乘來判斷相對位置:

day03_cross_product
  • \overrightarrow{A B} \times \overrightarrow{A P}晚缩,值為,故 PAB 左側
  • \overrightarrow{B C} \times \overrightarrow{B P}媳危,值為荞彼,故 PBC 左側
  • \overrightarrow{C A} \times \overrightarrow{C P},值為待笑,故 PAC 左側

<br />

綜合以上三個限制條件鸣皂,我們可以判斷 P\Delta ABC 內。

如果上面三個計算中有值為負的情況暮蹂,說明 P 在三角形外寞缝;如果有值為 0 的情況,說明 P 在三角形的邊或頂點上仰泻。

2.2 代碼實現

理論基礎復習完了荆陆,我們就可以寫代碼了。代碼實現相當簡單集侯,我們構建一個函數 crossProduct被啼,傳入三角形的三個頂點和平面上的任意一點 P,然后根據四個頂點構建出向量計算叉乘就可以了:

// 利用叉乘判斷是否在三角形內部
Vec3i crossProduct(Vec2i *pts, Vec2i P) {
    // 構建出三角形 ABC 三條邊的向量
    Vec2i AB(pts[1].x - pts[0].x, pts[1].y - pts[0].y);
    Vec2i BC(pts[2].x - pts[1].x, pts[2].y - pts[1].y);
    Vec2i CA(pts[0].x - pts[2].x, pts[0].y - pts[2].y);
    
    // 三角形三個頂點和 P 鏈接形成的向量
    Vec2i AP(P.x - pts[0].x, P.y - pts[0].y);
    Vec2i BP(P.x - pts[1].x, P.y - pts[1].y);
    Vec2i CP(P.x - pts[2].x, P.y - pts[2].y);
    
    return Vec3i(AB^AP, BC^BP, CA^CP);
}

<br />

代碼非常的簡單棠枉,我們跑一個簡單的例子驗證一下:

void drawSingleTriangle() {
    // 圖片的寬高
    int width  = 200;
    int height = 200;

    TGAImage frame(width, height, TGAImage::RGB);
    Vec2i pts[3] = {Vec2i(10, 10), Vec2i(150, 30), Vec2i(70, 160)};

    Vec2i P;
    // 遍歷圖片中的所有像素
    for (P.x = 0; P.x <= width - 1; P.x++) {
        for (P.y = 0; P.y <= height - 1; P.y++) {
            Vec3i bc_screen  = crossProduct(pts, P);

            // bc_screen 某個分量小于 0 則表示此點在三角形外(認為邊也是三角形的一部分)
            if (bc_screen.x<0 || bc_screen.y<0 || bc_screen.z<0) {
                continue;
            }
            
            image.set(P.x, P.y, color);
        }
    }
    
    frame.flip_vertically();
    frame.write_tga_file("output/day03_cross_product_triangle.tga");
}

<br />

看輸出圖像趟据,我們已經成功繪制了一個三角形:

day03_cross_product_triangle

觸不及防的安利:大家可以看我頭像關注???號「鹵蛋實驗室」,后臺回復「圖形學」獲取經典教材《虎書4》和《Real Time Rendering 4》

后臺回復「圖形學」領取經典教材

3.利用三角形重心坐標畫三角形

本小節(jié)介紹一個更通用的定理——重心坐標(Barycentric Coordinate)术健。其實重心坐標用來畫三角形還是有些大材小用了汹碱,他最重要的運用其實是用來做插值,不過插值的具體運用我們后續(xù)章節(jié)再探討荞估,今天我們看看重心坐標的推導和代碼實現咳促。

3.1 數學推導

我們暫時只考慮二維平面的三角形。對于一個三角形 \Delta ABC 來說勘伺,假設平面內有一個點 P跪腹,很顯然,\overrightarrow{A P}飞醉,\overrightarrow{A B}冲茸,\overrightarrow{A C} 向量都是線性相關的,也就是說缅帘,可以用下式表示 \overrightarrow{A P}

\overrightarrow{A P}=u \overrightarrow{A B}+v \overrightarrow{A C}

我們把這個三角形放在一個笛卡爾坐標系下轴术,我們就可以這樣表示:

A-P=u(A-B)+v(A-C)

把位置挪一下,合并同類項后钦无,P 點的位置可以表示為下式:

P=(1-u-v) A+u B+v C

<br />

結合幾何意義逗栽,我們很容易推出:

  • (1 - u - v)uv大于 0 小于 1 時失暂,P 位于三角形內部
  • 一個分量等于 0 時彼宠,P 在三角形
  • 兩個變量等于 0 時鳄虱,P 在某個頂點

<br />

再對第一個式子做一下變形,可以得到下式:

u \overrightarrow{A B}+v \overrightarrow{A C}+\overrightarrow{P A}=0

<br />

因為三角形位于笛卡爾坐標系內凭峡,我們可以把上面的式子沿 xy 軸拆分為兩個式子拙已,他們和上式是等價的:

\left\{\begin{array}{l}u \overrightarrow{A B}_{x}+v \overrightarrow{A C}_{x}+\overrightarrow{P A}_{x}=0 \\ u \overrightarrow{A B}_{y}+v \overrightarrow{A C}_{y}+\overrightarrow{P A}_{y}=0\end{array}\right.

<br />

觀察這個式子,我們可以轉換為矩陣乘法的形式:

\left[\begin{array}{lll}u & v & 1\end{array}\right]\left[\begin{array}{l}\overrightarrow{A B}_{x} \\ \overrightarrow{A C}_{x} \\ \overrightarrow{P A}_{x}\end{array}\right]=0

\left[\begin{array}{lll}u & v & 1\end{array}\right]\left[\begin{array}{l}\overrightarrow{A B}_{y} \\ \overrightarrow{A C}_{y} \\ \overrightarrow{P A}_{y}\end{array}\right]=0

<br />

觀察上面的式子摧冀,我們要尋找一個向量 [\begin{array}{lll}u & v & 1\end{array}]倍踪,它要與向量 [\begin{array}{l}\overrightarrow{A B}_{x} \ \overrightarrow{A C}_{x} \ \overrightarrow{P A}_{x}\end{array}][\begin{array}{l}\overrightarrow{A B}_{y} \ \overrightarrow{A C}_{y} \ \overrightarrow{P A}_{y}\end{array}] 同時點乘為 0

稍微思考一下按价,這不就意味著向量 [\begin{array}{lll}u & v & 1\end{array}] 同時垂直于向量 [\begin{array}{l}\overrightarrow{A B}_{x} \ \overrightarrow{A C}_{x} \ \overrightarrow{P A}_{x}\end{array}][\begin{array}{l}\overrightarrow{A B}_{y} \ \overrightarrow{A C}_{y} \ \overrightarrow{P A}_{y}\end{array}] 嗎惭适!

所以我們直接求后兩個向量的叉乘就可以求出向量 [\begin{array}{lll}u & v & 1\end{array}] 了笙瑟。

<br />

后兩個向量做叉乘的時候有個小細節(jié)需要注意一下楼镐,向量叉乘的直接結果(先假設結果為 [\begin{array}{lll}e & f & g\end{array}] )一般只是和 [\begin{array}{lll}u & v & 1\end{array}] 平行,想要正確求出 uv 的值往枷,我們需要對向量 [\begin{array}{lll}e & f & g\end{array}] 除以 g框产,也就是說 u = e/gv = f/g错洁,1 = g/g秉宿。

這個時候問題就來了,上面的除法成立屯碴,必須要建立在 g 不為 0 的基礎上描睦,那么我們就要研究一下 g 為 0 的數學含義了。

根據向量的叉乘公式:

\mathbf{u} \times \mathbf{v}=\left|\begin{array}{cc}u_{2} & u_{3} \\ v_{2} & v_{3}\end{array}\right| \mathbf{i}-\left|\begin{array}{cc}u_{1} & u_{3} \\ v_{1} & v_{3}\end{array}\right| \mathbf{j}+\left|\begin{array}{cc}u_{1} & u_{2} \\ v_{1} & v_{2}\end{array}\right| \mathbf{k}

g 向量可以表示為這樣的行列式:

\left|\begin{array}{ll}\overrightarrow{A B}_{x} & \overrightarrow{A C}_{x} \\ \overrightarrow{A B}_{y} & \overrightarrow{A C}_{y}\end{array}\right|

如果這時候的叉乘結果為 0导而,把這個行列式從列向量的視角看忱叭,就相當于 \overrightarrow{A B}\overrightarrow{A C} 向量叉乘結果為 0,也就是說 \overrightarrow{A B}\overrightarrow{A C} 向量共線今艺。這時候三角形 \Delta ABC退化為一條線段韵丑。

對于我們現在應用場景來說,只要檢測到 g 為 0虚缎,就意味著這個三角形退化為一條線段了撵彻,我們直接舍棄掉它就好了,對最后的結果也沒有影響实牡。

3.2 代碼實現

根據上面的公式推導陌僵,我們可以直接寫出基于三角形重心坐標的繪制算法,思路理清了创坞,代碼實現就非常的簡單:

// 利用重心坐標判斷點是否在三角形內部
Vec3f barycentric(Vec2i *pts, Vec2i P) {
    Vec3i x(pts[1].x - pts[0].x, pts[2].x - pts[0].x, pts[0].x - P.x);
    Vec3i y(pts[1].y - pts[0].y, pts[2].y - pts[0].y, pts[0].y - P.y);
    
    // u 向量和 x y 向量的點積為 0拾弃,所以 x y 向量叉乘可以得到 u 向量
    Vec3i u = x^y;
    
    // 由于 A, B, C, P 的坐標都是 int 類型,所以 u.z 必定是 int 類型摆霉,取值范圍為 ..., -2, -1, 0, 1, 2, ...
    // 所以 u.z 絕對值小于 1 意味著三角形退化了豪椿,直接舍棄
    if(std::abs(u.z) < 1) {
        return Vec3f(-1, 1, 1);
    }
    return Vec3f(1.f- (u.x+u.y) / (float)u.z, u.x / (float)u.z, u.y / (float)u.z);
}

<br />

用上面的算法畫個三角形驗證一下奔坟,很完美:

day03_barycentric_triangle

4.繪制模型

算法寫好了,我們就要投入到實際應用中了搭盾,昨天里我們畫了個三維模型的線框圖咳秉,今天我們就個這個模型上色。

4.1 隨機著色

為了區(qū)分每個三角形鸯隅,我們隨機給三角形上不同的色:

triangle(screen_coords, frame, TGAColor(rand() % 255, rand() % 255, rand() % 255, 255));

<br />

這里還有個關于性能的小細節(jié)澜建。這次我們繪制的圖像大小為 800*800,如果按照之前的算法蝌以,每次畫三角形炕舵,都要把所有像素遍歷一遍,這個模型大概有 2000 個三角形跟畅,那就是要循環(huán) 2000*800*80012.8 億 次咽筋!

這個量級是很恐怖的,其中很多的運算都是不必要的徊件,比如說下圖奸攻,我們其實只要循環(huán)由三個頂點計算出的紅色包圍盒里的像素就可以了,不需要計算圖片內的所有像素:

day03_bounding_box

<br />

所以我們要在遍歷像素前加先求一遍三角形包圍盒的邊界虱痕,正式畫三角形時只要遍歷包圍盒內的像素就可以了:

// 定義包圍盒
Vec2i boxmin(image.get_width() - 1, image.get_height() - 1);
Vec2i boxmax(0, 0);
// 圖片的邊界
Vec2i clamp(image.get_width() - 1, image.get_height() - 1);

// 查找包圍盒邊界
for (int i = 0; i < 3; i++) {
    // 第一層循環(huán)睹耐,遍歷三角形的三個頂點
    for (int j = 0; j < 2; j++) {
        // 第二層循環(huán),根據頂點數值縮小包圍盒的范圍
        boxmin.x = std::max(0,        std::min(boxmin.x, pts[i].x));
        boxmin.y = std::max(0,        std::min(boxmin.y, pts[i].y));

        boxmax.x = std::min(clamp.x, std::max(boxmax.x, pts[i].x));
        boxmax.y = std::min(clamp.y, std::max(boxmax.y, pts[i].y));
    }
}

<br />

最后我們繪制出的結果就是這樣的部翘,看著還是有些酷炫的:

day03_rand_colors_model

4.2 簡單的光照著色

隨機著色的好處是可以很清楚的表現出模型各個三角形的輪廓硝训,但是也失去了模型的辨識度,很多細節(jié)都丟失了新思。

我們在這里引入一個非常簡單的光照模型窖梁,認為單位面積上接收到的光,和平面法線與光照方向的余弦值成正比

day03_diffuse_reflection

<br />

所以著色的思路就很清晰了:

  • 我們要先定義一個三維空間里的光照方向(向量)表牢,然后計算三維空間里各個三角形的法線(向量)
  • 兩個向量歸一化后窄绒,然后計算這兩個向量的點乘,會得到一個值
  • 這個值小于 0崔兴,說明光在三角形的另一側彰导,從物理上看是照射不到三角形表面的,所以直接舍棄此三角形
  • 這個值大于 0敲茄,值越大位谋,說明單位面積上接收到的光越多,三角形越亮

<br />

把上面的思路翻譯成代碼就是這樣的:

// 這個是用一個模擬光照對三角形進行著色

Vec3f light_dir(0, 0, -1); // 假設光是垂直屏幕的

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(); // 對 n 做歸一化處理

    // 三角形法線和光照方向做點乘掏父,點乘值大于 0,說明法線方向和光照方向在同一側
    // 值越大秆剪,說明越多的光照射到三角形上赊淑,顏色越白
    float intensity = n * light_dir;
    if (intensity > 0) {
        triangle(screen_coords, frame, TGAColor(intensity * 255, intensity * 255, intensity * 255, 255));
    }
}

<br />

最后生成的圖片就是這樣的:

day03_light_model

<br />

到這里渲染出的圖像就有些人樣了爵政,但是大家應該也發(fā)現了,上圖的嘴巴和眼睛處看上去怪怪的陶缺。這里的確是有問題钾挟,因為它把背后的三角形渲染出來了,要想解決這個問題饱岸,就要引入一個新的概念——z-buffer掺出。

推薦直接閱讀原文,更新更及時苫费,閱讀體驗更佳


如果你喜歡我的文章汤锨,希望點贊?? 收藏 ?? 在看 ?? 三連支持一下!0倏颉闲礼!謝謝你,這對我真的很重要琅翻!

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末位仁,一起剝皮案震驚了整個濱河市柑贞,隨后出現的幾起案子方椎,更是在濱河造成了極大的恐慌,老刑警劉巖钧嘶,帶你破解...
    沈念sama閱讀 218,858評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件棠众,死亡現場離奇詭異,居然都是意外死亡有决,警方通過查閱死者的電腦和手機闸拿,發(fā)現死者居然都...
    沈念sama閱讀 93,372評論 3 395
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來书幕,“玉大人新荤,你說我怎么就攤上這事√ɑ悖” “怎么了苛骨?”我有些...
    開封第一講書人閱讀 165,282評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長苟呐。 經常有香客問我痒芝,道長,這世上最難降的妖魔是什么牵素? 我笑而不...
    開封第一講書人閱讀 58,842評論 1 295
  • 正文 為了忘掉前任严衬,我火速辦了婚禮,結果婚禮上笆呆,老公的妹妹穿的比我還像新娘请琳。我一直安慰自己粱挡,他們只是感情好,可當我...
    茶點故事閱讀 67,857評論 6 392
  • 文/花漫 我一把揭開白布俄精。 她就那樣靜靜地躺著抱怔,像睡著了一般。 火紅的嫁衣襯著肌膚如雪嘀倒。 梳的紋絲不亂的頭發(fā)上屈留,一...
    開封第一講書人閱讀 51,679評論 1 305
  • 那天,我揣著相機與錄音测蘑,去河邊找鬼灌危。 笑死,一個胖子當著我的面吹牛碳胳,可吹牛的內容都是我干的勇蝙。 我是一名探鬼主播,決...
    沈念sama閱讀 40,406評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼挨约,長吁一口氣:“原來是場噩夢啊……” “哼味混!你這毒婦竟也來了?” 一聲冷哼從身側響起诫惭,我...
    開封第一講書人閱讀 39,311評論 0 276
  • 序言:老撾萬榮一對情侶失蹤翁锡,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后夕土,有當地人在樹林里發(fā)現了一具尸體馆衔,經...
    沈念sama閱讀 45,767評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年怨绣,在試婚紗的時候發(fā)現自己被綠了角溃。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,090評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡篮撑,死狀恐怖减细,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情赢笨,我是刑警寧澤未蝌,帶...
    沈念sama閱讀 35,785評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站质欲,受9級特大地震影響树埠,放射性物質發(fā)生泄漏。R本人自食惡果不足惜嘶伟,卻給世界環(huán)境...
    茶點故事閱讀 41,420評論 3 331
  • 文/蒙蒙 一怎憋、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦绊袋、人聲如沸毕匀。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,988評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽皂岔。三九已至,卻和暖如春展姐,著一層夾襖步出監(jiān)牢的瞬間躁垛,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,101評論 1 271
  • 我被黑心中介騙來泰國打工圾笨, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留教馆,地道東北人。 一個月前我還...
    沈念sama閱讀 48,298評論 3 372
  • 正文 我出身青樓擂达,卻偏偏與公主長得像土铺,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子板鬓,可洞房花燭夜當晚...
    茶點故事閱讀 45,033評論 2 355

推薦閱讀更多精彩內容