Abstract
上一節:Bresemham直線繪制
Reference :
經過第一部分學習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);
}
而本部分關注如何在三角形內部填充顏色。
掃描線算法 Line Sweeping
掃描線算法基本思想為,根據頂點的 \(y\) 值從下到上地,逐步從三角形左邊界到右邊界繪制直線,直到到達三角形最高點繪制完畢:
算法步驟采用三線性插值法,繪制要分別考慮被頂點 \(v1\) 所在水平線分割的上下部分,因為插值與三個向量代表的微分方向有關:
-
\(\vec{v_{0}v_{2}}\)
-
\(\vec{v_{0}v_{1}}\)
-
\(\vec{v_{1}v_{2}}\)
每繪制一條直線時,需從它左端點一個個畫點畫到其右端點,因此每條直線的左、右端點的采樣是同時進行的。
在上圖例中,左邊界 \(\vec{v_{0}v_{2}}\) 上端點的采樣范圍是 \([v_{0}, v_{2}]\) ,插值比例系數來自於 \(v_0\) 和 \(v_2\) 之間豎直方向位移 \(h1\) 的插值結果。
同理,右邊界分為 \(\vec{v_{0}v_{1}}\) 與 \(\vec{v_{1}v_{2}}\) 兩部分。第一部分和第二部分端點的采樣范圍分別是 \([v_{0}, v_{1}]\) 與 \([v_{1}, v_{2}]\),插值比例系數分別來自於豎直方向位移 \(h2\) 與 \(h3\) 以及單條掃描線左端到右端 \(x\) 的插值結果:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
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 h1 = t2.y - t0.y;
int h2 = t1.y - t0.y;
int h3 = t2.y - t1.y;
for(int i = 0; i <= h1; i++) {
bool halfPart = i > h2 || t1.y == t0.y;
// segmentHeight的值一定不為0,不會發生除0錯誤
int segmentHeight = halfPart ? h3 : h2;
float alpha = (float)i / h1;
float beta = (float)(i - (halfPart ? h2 : 0)) / segmentHeight;
Vec2i startPoint = t0 + (t2-t0)*alpha;
Vec2i endPoint = halfPart ? t1 + (t2-t1)*beta : t0 + (t1-t0)*beta;
if(startPoint.x > endPoint.x) std::swap(startPoint, endPoint);
for(int j = startPoint.x; j <= endPoint.x; j++) {
image.set(j, t0.y+i, color);
}
}
}
-
繪制循環中,變量 \(i\) 為 \(\vec{v_{0}v_{2}}\) 方向的插值步長,影響左端點采樣。
-
halfPart 表征當前是否已經到達第二部分,也即 \(\vec{v_{1}v_{2}}\) 的插值方向;\(v_0\) 與 \(v_1\) 處同一水平線的情形同樣被包含於 halfPart 的語境中。
-
halfPart 的值決定使用位移 \(h2\) 還是 \(h3\) 來進行右端點插值采樣,並將值賦予變量 segmentHeight。
-
三線性插值的第一次插值:變量 \(alpha\) 代表 \(h1\) 位移上的比例系數;\(beta\) 代表 \(h2\) / \(h3\) 位移上的比例系數(\(h2\) 還是 \(h3\) 由 halfPart 決定)。
-
三線性插值的第二次插值(采樣):startPoint和endPoint分別為當前直線左右端點。此次插值(采樣)受第一次插值算出的比例系數影響。
-
三線性插值的第三次插值(采樣):用一個循環計算startPoint和endPoint之間的點。
繪制效果:
可以看到各種情況都能正確繪制,左下角藍色三角形就是 \(v_0\) 與 \(v_1\) 處同一水平線的情形。
重心坐標法 Barycentric Coordinates
假設我們能將三角形用一個包圍盒BoundingBox框起來,然后依次遍歷包圍盒中的所有點,若點落在三角形內就進行着色,否則不進行任何操作。偽代碼看起來像這樣:
填充三角形(三角形) {
包圍盒 = 找到三角形的包圍盒(三角形的頂點);
for (包圍盒里每一個點) {
if (這個點在三角形內) {
着色(此點);
}
}
}
這個算法重點是判定點是否在三角形內,對此采用重心坐標法。
給定一個2D三角形和一個點P,目標是計算相對於此三角形點P的重心坐標 \(\alpha\) 、\(\beta\)、\(\gamma\),且 \(\alpha + \beta + \gamma = 1\)。實際上,只要計算出 \(\beta\) 與 \(\gamma\),\(\alpha\) 用 \(1 - \beta - \gamma\) 表示即可,然后我們可以確定點P位置:
\(P = \alpha A + \beta B + \gamma C\) \((1)\)
可想象為,僅有質心的三個重物 \((1-\beta-\gamma), \beta, \gamma\) 分別置於三角形三點,則此時點P為此三角形的重心。(仔細想想,這是不是另一種版本的線性插值呢?)
我們稍微將思路調整一下,建立一個新坐標系。原點為A,基為 \(\vec{AC}\) 、\(\vec{AB}\),則P可表示為:
\(P = A + \beta \vec{AB} + \gamma \vec{AC}\) \((2)\)
將A移到左邊,則有:
\(\vec{AP} = \beta \vec{AB} + \gamma \vec{AC}\) \((3)\)
這樣,我們便將情形轉化到了向量表示上:
變換 \((3)\) 式:
\(\beta \vec{AB} + \gamma \vec{AC} + \vec{PA} = 0\) \((4)\)
將向量拆成 \((x,y)\) 笛卡爾坐標形式,便能得到線性方程:
\( \begin{equation} \begin{cases} \beta \cdot \vec{AB}_{x} + \gamma \cdot \vec{AC}_{x} + \vec{PA}_{x} = 0\\ \beta \cdot \vec{AB}_{y} + \gamma \cdot \vec{AC}_{y} + \vec{PA}_{y} = 0 \end{cases} \end{equation} \)
將其轉為線性代數方程:
\( \begin{equation} \begin{cases} \left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right]\left[\begin{array}{c} \vec{AB}_{x} \\ \vec{AC}_{x} \\ \vec{PA}_{x} \end{array}\right] = 0\\\\ \left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right]\left[\begin{array}{c} \vec{AB}_{y} \\ \vec{AC}_{y} \\ \vec{PA}_{y} \end{array}\right] = 0 \end{cases} \end{equation} \)
現在設向量
-
\(\vec{n} = \left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right]\)
-
\(\vec{i} = \left[\begin{array}{ccc} \vec{AB}_{x} & \vec{AC}_{x} & \vec{PA}_{x} \end{array}\right]\)
-
\(\vec{j} = \left[\begin{array}{ccc} \vec{AB}_{y} & \vec{AC}_{y} & \vec{PA}_{y} \end{array}\right]\)
神奇的事情出現了。沒錯,這個方程組表示,向量 \(\vec{n}\) 同時正交(垂直)於向量 \(\vec{i}\) 和 \(\vec{j}\) 。
感覺熟悉嗎?換句話說,\(\vec{n} = \left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right]\) 是 \(\vec{i} \times \vec{j}\) (叉積/外積)得到的法向量。
目前為止,你或許會感覺這些計算非常反直覺:\(\beta\) 與 \(\gamma\) 怎么就被包含在一個向量里了?向量 \(\vec{i}, \vec{j}\) 又是什么?我非常贊同,但誰叫數學如此巧妙呢?
有了上述結論,我們便可用 \(\vec{i} \times \vec{j}\) 來求我們需要的 \(\beta\)、\(\gamma\),而 \(\vec{i}\)、\(\vec{j}\) 是可通過已知變量算出來的!然而\(\vec{i}\) 與 \(\vec{j}\)
叉積算出來的向量的形式不一定是 \(\left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right]\),而是:
\(k \cdot \left[\begin{array}{ccc} \beta & \gamma & 1 \end{array}\right] = \left[\begin{array}{ccc} k\beta & k\gamma & k \end{array}\right]\) , \((k \in \mathbb{R}^*)\)
這是因為叉積算出來的本質上是 \(\vec{n}\) 的共線向量,恢復到 \(\vec{n}\) 只需將該向量的每個分量同時除以 \(k\) 即可,而 \(k\) 其實就是該向量的 \(z\) 值:
提前消除一個疑慮:叉積算出的向量 \(z\) 值為負是叉積順序導致的結果,回憶一下叉積的右手螺旋定則,這會影響結果法向量的方向。
有了以上結論,我們可以着手計算了。得到的結果除以其 \(z\) 值之后的 \(\vec{n}\),會有三種情況,分別是:
1. 三角形退化情況
此時 \(\vec{n}\) 的 \(z\) 值為 \(0\) 。回想一下,從叉積幾何意義的角度來看,此時參與運算的 \(\vec{i}, \vec{j}\) 平行;
從另一個角度看,求重心坐標的“三角形”一定三點共線甚至兩兩重合,此時“三角形”至少退化degenerate為一條線段了,甚至變為一個點!不信的話我用叉積的坐標運算定義來證明:
\(\vec{n}_z = \vec{i}_x*\vec{j}_y-\vec{j}_x*\vec{i}_y = \vec{AB}_{x}*\vec{AC}_{y}-\vec{AC}_{x}*\vec{AB}_{y} = 0\)
\(\Rightarrow \vec{AB}_{x}*\vec{AC}_{y} = \vec{AC}_{x}*\vec{AB}_{y}\)
\(\Rightarrow \frac{\vec{AB}_{x}}{\vec{AB}_{y}} = \frac{\vec{AC}_{x}}{\vec{AC}_{y}}\)
也就是說,此時向量 \(\vec{AB}\) 與 \(\vec{AC}\) 共線(回憶我們在上面推導的向量共線等式),“三角形”看起來是這樣的:
2. 點 \(P\) 在三角形外的情況 --- \(\beta\)、\(\gamma\) 至少有一個為負
光是看幾何表示,我們就能確定,點 \(P\) 不在三角形內部時, \(\beta\)、\(\gamma\) 至少有一個為負。但是在計算過程中我們會發現,計算完 \(\vec{i} \times \vec{j}\) 得到的向量在還沒有除以 \(z\) 之前,其 \(\beta\) 與 \(\gamma\) 有可能是正的。
但如果你腦袋轉得夠快,會明白這是由於叉積的順序不正確,導致得出的向量與我們要的 \(\vec{n}\) 的方向相反。這時,只需將向量除以其 \(z\) 值,結果便會恢復正常。
3. 點 \(P\) 在三角形內的情況 --- 這是我們想要的!
此時便不用推導任何公式了,因為情形3正好與情形2相反:\(\beta\)、\(\gamma\) 均為正數。
呼,沒想到一個看似簡單的判定算法竟然花費我們這么多時間。結束了在公式上的掙扎,終於可以將結論付諸於代碼了:
// 若返回的重心坐標有含負號的成員,則P被判定為無效點
Vec3f barycentric(Vec2i *pts, Vec2i P) {
// 叉積
Vec3f result = 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]));
// 三角形退化情況
// result除以自身z之前,其z值可能為負
// z值為int型,它的絕對值小於1,說明它等於0,此時返回帶負號的重心坐標
if (std::abs(u[2])<1) return Vec3f(-1,1,1);
// result除以z恢復為我們要的n
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++) {
// 外層的std::max確保將坐標為負的點剔除
bboxmin[j] = std::max(0, std::min(bboxmin[j], pts[i][j]));
// 外層的std::min確保將超出屏幕外的點剔除
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);
}
}
}
原作者寫的代碼非常優雅巧妙。我相信構造包圍盒的代碼中,
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]));
的外層是用來處理用戶不當調用的邊界判定。
想象一下,三角形頂點在由世界坐標變為屏幕坐標后才會被傳到triangle函數進行光柵化,此時頂點坐標一定為非負且被限制在屏幕大小內,這時即使不加這兩個外層判定,一樣不會發生程序異常;但若用戶未進行視區變換便將頂點的世界坐標直接傳入triangle,那么這兩層判定便能很好把握住邊界處理。
擴展:簡單的平面着色渲染
下圖是對一個模型的二維三角形面片使用我們的代碼進行隨機上色的例子(其wireframe表示由右圖展示,它由Bresenham直線算法繪制):
但是這樣的着色太過粗暴了,我們來考慮一個更接近現實的例子。
假設場景里有一個光源,多邊形與光線正交時其接收光照強度最高;而若光線與多邊形平行時,其接收的光照強度幾乎為0。
設光線矢量為 \(\vec{l}\) ,平面法線為 \(\vec{n}\) 。換言之,光照強度與 \(\vec{l}\) 和 \(\vec{n}\) 之間的夾角 \(\theta\) 成負相關,這和 \(cos\theta\) 在區間 \([0, \frac{\pi}{2}]\) 單調遞減的性質相契合。我們試試計算光線與平面法向量的內積:
\(\vec{l} \cdot \vec{n} = |\vec{l}| |\vec{n}|cos\theta\)
由於它們會被正規化為單位向量,也即 \(|\vec{l}| |\vec{n}| = 1\),那么上式變為:\(\vec{l} \cdot \vec{n} = cos\theta\),這正是我們想要的。
你或許會思考這種情形:光線從平面的背面照過來,或者計算出的法向量為負。此時光線與平面法向量夾角 \(\theta \gt \frac{\pi}{2}\) ,\(cos\theta \lt 0\)。那么只需在代碼判斷時,若內積為負則不進行着色。這在圖形學中叫做 Back-face culling背面剔除
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();
float intensity = n*light_dir;
// Back-face culling
if (intensity>0){
triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
}
}
效果如圖:
你會發現口腔內的點竟然凸出來了,這是這種算法的一個缺點。下一節會用深度測試來修復這種問題。