在幾何階段我們通過頂點變換獲得了世界坐標下的頂點最終渲染到屏幕上的位置和它們的深度值,并且在剔除掉了不在視錐體內頂點,接下來要做的就是根據頂點的位置和三角形索引渲染出模型的每一個三角形。
這個簡單的光柵器會實現三種渲染模式,分別是貼圖模式、頂點插值和線框模式。
對于線框模式,需要做的就是根據每一個邊的端點的坐標,通過計算,找出最擬合這條直線的一系列的像素。
而貼圖模式和頂點插值模式模式的渲染方式是將三角形切成若干條平行于x軸的直線,一般把這些直線叫做掃描線,劃分好掃描線后,就可以根據掃描線左側的起始點和右側的終止點進行插值,計算出掃描線上每個像素點的xy坐標,uv坐標和深度值等信息。
畫線算法(Bresenham算法)
下面介紹Bresenham算法的基本思想,假設直線左下角的點為v1,右上角的點為v2
以上圖為例,我們可以知道直線的斜率k小于1,即x每增加單位距離dx,y的變化量dy要比x小,在這種情況下我們以x為基準執行算法
- 從點v1開始,x坐標每一次迭代增加單位距離,y坐標的不變
- 在直線v1v2上x坐標每增加單位距離,y坐標的變化量dy是相同的,使用一個變量error記錄y的累積的變化量
- 當y坐標累積的變化量error大于等于x的單位距離的時候,將y的累積的變化量error減去x的單位距離,同時下次迭代的時候將y坐標的值增加單位距離
上圖所描述的情況直線在v1v2方向上,隨著x的增加y也是增加的。如果y隨x的增加而減少,那么在步驟3中修改y坐標時就應該減去單位距離
同理,當直線的斜率k大于1的時候,只需要以y軸為基準,執行上述算法即可
另外還有三種特殊的情況,即直線重合為一點、與x軸平行和與y軸平行,這三種情況處理起來就比較簡單了
可以看到Bresenham算法通過采用統計誤差的方式,使用步進的思想,使得每次迭代的時候只要檢查一個誤差項,就可以確定該列所求的像素
另外Bresenham算法避免了浮點運算,效率較高也避免了浮點數帶來的誤差
下面這段代碼是光柵器中運用Bresenham算法的部分,在實現的時候可以將y坐標累積的變化量error和x的單位距離同時增大(v2x-v1x)倍,這樣每次y的積累的變化量就是(v2y-v1y)了,x的單位距離擴大(v2x-v1x)倍后就是(v2x-v1x)了,這樣可以減少很多計算
void Device::DrawLine(Vector2i& v1, Vector2i& v2, _UINT32 color)
{
//兩個點重合的情況
if (v1 == v2)
{
DrawPixel(v1._x, v1._y, color);
}
//直線平行于y軸的情況
else if (v1._x == v2._x)
{
_INT32 dir = v2._y > v1._y ? 1 : -1;
for (auto y = v1._y; y != v2._y; y += dir)
{
DrawPixel(v1._x, y, color);
}
DrawPixel(v2._x, v2._y, color);
}
//直線平行于x軸的情況
else if (v1._y == v2._y)
{
_INT32 dir = v2._x > v1._x ? 1 : -1;
for (auto x = v1._x; x != v2._x; x += dir)
{
DrawPixel(x, v1._y, color);
}
DrawPixel(v2._x, v2._y, color);
}
//其它情況
else
{
_INT32 dx = Abs(v1._x - v2._x);
_INT32 dy = Abs(v1._y - v2._y);
_INT32 error = 0;
//斜率小于1的情況
if (dx > dy)
{
if (v1._x > v2._x)
{
Swap(v1._x, v2._x);
Swap(v1._y, v2._y);
}
_INT32 dir = v2._y > v1._y ? 1 : -1;
for (auto x = v1._x, y = v1._y; x <= v2._x; x++)
{
DrawPixel(x, y, color);
error += dy;
if (error >= dx)
{
error -= dx;
y += dir;
DrawPixel(x, y, color);
}
}
DrawPixel(v2._x, v2._y, color);
}
//斜率大于1的情況
else
{
if (v1._y > v2._y)
{
Swap(v1._x, v2._x);
Swap(v1._y, v2._y);
}
_INT32 dir = v2._x > v1._x ? 1 : -1;
for (auto y = v1._y, x = v1._x; y <= v2._y; y++)
{
DrawPixel(x, y, color);
error += dx;
if (error >= dy)
{
error -= dy;
x += dir;
DrawPixel(x, y, color);
}
}
DrawPixel(v2._x, v2._y, color);
}
}
}
void inline Device::DrawPixel(_INT32 x, _INT32 y, _UINT32 color)
{
if (x >= 0 && x < _width && y < _height && y >= 0)
{
_frameBuffer[y][x] = color;
}
}
掃描線算法
在計算掃描線的時候,三角形最好有一條邊能夠和x平行,但是實際情況往往并沒有這么理想
對于非理想情況,如果能將其轉換為上面的理想情況,處理起來就會方便許多
首先對三角形的三個頂點按照y坐標從小到大進行排序,然后分情況討論
當v1v2或者v2v3在同一條直線上的時,對應之前的理想情況
除了上述兩種情況外,還剩下下面兩種情況,我們只需要過v2做一條平行線就能將一個三角形切割為兩個理想狀態下的三角形了
掃描線的方向最好是統一的,不然在用代碼實現的時候一下子從左到右,一下子從右到左,在計算插值的時候會比較麻煩
所以對于圖中的兩種情況,需要去區分v1v2和v2v3到底是在v1v3的左側還是右側
判斷的方法先過v3做一條平行于x軸的直線A,然后過v1做一條垂直于直線A的直線B,延長v1v2交直線A與點P。接下來過v1做一條平行于x軸的直線C,之后過v2做一條垂直于直線C的直線D
當v1v2和v2v3在v1v3的右側時,輔助線如下圖所示(兩種情況),
當v1v2和v2v3在v1v3的左側時,情況和上述情況是鏡像的,可以自行腦補將圖片翻轉
可以觀察到藍色的三角形和紅色的三角形是相似的,所以可以求出P的x坐標
通過當p點的x坐標大于v3點的x坐標的時候,v1v2和v2v3在v1v3的右側;反之,當p點的x坐標小于v3點的x坐標的時候,v1v2和v2v3在v1v3的左側
這樣就構造好了掃描線了,在實際渲染的時候對于每一個三角形,只需要渲染對應三角形的所有掃描線即可。
下面進行編碼
Color類存儲了像素的RGB信息
class Color
{
public:
_FLOAT _r, _g, _b;
public:
Color();
Color(_FLOAT r, _FLOAT g, _FLOAT b);
Color(const Color& other);
Color& operator = (const Color& other);
public:
Color operator + (const Color& c) const;
Color operator + (_FLOAT offset) const;
Color operator - (const Color& c) const;
Color operator - (_FLOAT offset) const;
Color operator * (const Color& c) const;
Color operator * (_FLOAT offset) const;
};
Vertex類記錄了頂點的位置,紋理坐標,顏色和深度信息,Init函數負責在對紋理坐標進行1/z插值時對頂點數據進行處理
class Vertex
{
public:
Vector4f _position;
_FLOAT _u, _v;
Color _color;
_FLOAT _deepz;
public:
Vertex();
Vertex(Vector4f position, _FLOAT u, _FLOAT v, Color color);
Vertex(const Vertex& other);
Vertex& operator = (const Vertex& other);
public:
Vertex operator - (const Vertex& other) const;
Vertex operator + (const Vertex& other) const;
Vertex operator * (_FLOAT scale) const;
public:
void Init();
};
Line類記錄了線段的兩個端點和線段上的某一個點(表示生成掃描線的時的起點或者終點)
class Line
{
public:
Vertex _v, _vertex1, _vertex2;
public:
Line(){}
Line(Vertex v, Vertex vertex1, Vertex vertex2);
};
Triangle類記錄了三角形的三個頂點
class Triangle
{
public:
Vertex _vertex1, _vertex2, _vertex3;
public:
Triangle(){}
Triangle(Vertex vertex1, Vertex vertex2, Vertex vertex3);
public:
bool IsTriangle() const;
};
Trapezoid記錄了掃描線的集合的梯形,它記錄了開始掃描的y坐標(top)和結束掃描的y坐標(bottom),以及掃描線集合梯形的兩邊,GetTrapezoids函數負責將不規則的三角形劃分為兩個上面提到的理想情況下的三角形,GetEndPoint函數負責獲取掃描線的起點和終點,InitScanline函數負責獲取掃描線對象
class Trapezoid
{
public:
_FLOAT _top, _bottom;
Line _left, _right;
public:
Trapezoid(){}
Trapezoid(_FLOAT top, _FLOAT bottom, Line left, Line right);
static _INT32 GetTrapezoids(const Triangle& triangle, Trapezoid* trapezoids);
void GetEndPoint(_FLOAT y);
Scanline InitScanline(_INT32 y);
};
_INT32 Trapezoid::GetTrapezoids(const Triangle& triangle, Trapezoid* trapezoids)
{
if (trapezoids == NULL)
{
return 0;
}
Vertex v1 = triangle._vertex1;
Vertex v2 = triangle._vertex2;
Vertex v3 = triangle._vertex3;
//對三個點進行排序
if (v1._position._y > v2._position._y)
{
Swap(v1, v2);
}
if (v1._position._y > v3._position._y)
{
Swap(v1, v3);
}
if (v2._position._y > v3._position._y)
{
Swap(v2, v3);
}
if (triangle.IsTriangle() == false)
{
return 0;
}
//理想情況一,v1v2平行于x軸
if (v1._position._y == v2._position._y)
{
if (v1._position._x > v2._position._x)
{
Swap(v1, v2);
}
if (v1._position._y >= v3._position._y)
{
return 0;
}
trapezoids[0]._top = v1._position._y;
trapezoids[0]._bottom = v3._position._y;
trapezoids[0]._left._vertex1 = v1;
trapezoids[0]._left._vertex2 = v3;
trapezoids[0]._right._vertex1 = v2;
trapezoids[0]._right._vertex2 = v3;
return 1;
}
//理想情況二,v2v3平行于x軸
if (v2._position._y == v3._position._y)
{
if (v2._position._x > v3._position._x)
{
Swap(v2, v3);
}
if (v1._position._y >= v3._position._y)
{
return 0;
}
trapezoids[0]._top = v1._position._y;
trapezoids[0]._bottom = v3._position._y;
trapezoids[0]._left._vertex1 = v1;
trapezoids[0]._left._vertex2 = v2;
trapezoids[0]._right._vertex1 = v1;
trapezoids[0]._right._vertex2 = v3;
return 1;
}
//不理想情況,需要劃分三角形
trapezoids[0]._top = v1._position._y;
trapezoids[0]._bottom = v2._position._y;
trapezoids[1]._top = v2._position._y;
trapezoids[1]._bottom = v3._position._y;
//計算P點的x坐標
_FLOAT x, k;
k = (v3._position._y - v1._position._y) / (v2._position._y - v1._position._y);
x = (v2._position._x - v1._position._x) * k + v1._position._x;
//v2在v1v3左側時
if (x < v3._position._x)
{
trapezoids[0]._left._vertex1 = v1;
trapezoids[0]._left._vertex2 = v2;
trapezoids[0]._right._vertex1 = v1;
trapezoids[0]._right._vertex2 = v3;
trapezoids[1]._left._vertex1 = v2;
trapezoids[1]._left._vertex2 = v3;
trapezoids[1]._right._vertex1 = v1;
trapezoids[1]._right._vertex2 = v3;
}
//v2在v1v3右側時
else
{
trapezoids[0]._left._vertex1 = v1;
trapezoids[0]._left._vertex2 = v3;
trapezoids[0]._right._vertex1 = v1;
trapezoids[0]._right._vertex2 = v2;
trapezoids[1]._left._vertex1 = v1;
trapezoids[1]._left._vertex2 = v3;
trapezoids[1]._right._vertex1 = v2;
trapezoids[1]._right._vertex2 = v3;
}
return 2;
}
Z-Buffer消隱算法
在渲染掃描線的時候,還需要考慮到深度的問題,即靠近攝像機的像素會遮擋住它后面的像素,深度檢測使用的算法就是Z-Buffer消隱算法。
在前面的頂點轉換并剔除視錐體外面的點后,z分量就失去了意義了,在裁剪變換的時候w分量被賦予了世界坐標系下z分量的信息,在光柵化插值的時候我們需要根據這個z分量的倒數1/z來進行插值,求出中間其它點的z分量。
z分量代表了空間中點的深度信息,即z值越小,點距離攝像機越近。為了提高性能,我們可以先計算好1/z的值,在插值和深度檢測的時候統一使用1/z來進行,即1/z越大,點距離攝像機越近。
Z-Buffer消隱算法的流程如下:
- 屏幕上每一個位置分別有一個深度緩存(zbuffer)和像素緩存(pixelbuffer),用于記錄當前位置的像素和深度信息
- 繪制點之前需要首先比對該點的深度值和屏幕對應位置的深度緩存中的深度值,如果比對的結果是該點距離攝像機更近,那么則更新深度緩存(zbuffer)的值并覆蓋該位置的像素緩存(pixelbuffer),如果比對的結果是該點距離攝像機更遠,則舍棄這個點
紋理插值
在進行紋理坐標uv插值的時候需要對u/z,v/z進行線性插值計算出新的uv值,在前面介紹坐標變換的時候已經證明過這個問題。
下面代碼負責的就是渲染一個三角形
void Device::RenderTriangle(Vertex& v1, Vertex& v2, Vertex& v3)
{
//對三角形的頂點進行坐標變換,將其變換到裁剪空間
Vector4f c1 = _transform->ApplyTransform(v1._position);
Vector4f c2 = _transform->ApplyTransform(v2._position);
Vector4f c3 = _transform->ApplyTransform(v3._position);
//剔除掉不在變換后的視錐長方體中的頂點
if (_transform->CheckCVV(c1) != 0 ||
_transform->CheckCVV(c2) != 0 ||
_transform->CheckCVV(c3) != 0)
{
return;
}
//對變換后的頂點進行其次除法,并映射到屏幕坐標
Vector4f h1 = _transform->Homogenize(c1);
Vector4f h2 = _transform->Homogenize(c2);
Vector4f h3 = _transform->Homogenize(c3);
//非線框模式需要繪制整個三角形
if (_renderState & (RENDER_STATE_COLOR | RENDER_STATE_TEXTURE))
{
Trapezoid trapezoids[2];
//構造一個Triangle對象,坐標為屏幕坐標,w分量存儲著頂點在空間中的深度信息z
Triangle triangles = Triangle(v1, v2, v3);
triangles._vertex1._position = h1;
triangles._vertex2._position = h2;
triangles._vertex3._position = h3;
triangles._vertex1._position._w = c1._w;
triangles._vertex2._position._w = c2._w;
triangles._vertex3._position._w = c3._w;
//要uv坐標進行插值1/z插值,需要先把uv變成u/z和v/z
triangles._vertex1.Init();
triangles._vertex2.Init();
triangles._vertex3.Init();
//劃分三角形
_INT32 n = Trapezoid::GetTrapezoids(triangles, trapezoids);
if (n >= 1)
{
//RenderTrapezoid函數調用后面的DrawScanline函數一行一行地繪制掃描線
RenderTrapezoid(trapezoids[0]);
}
if (n >= 2)
{
//RenderTrapezoid函數調用后面的DrawScanline函數一行一行地繪制掃描線
RenderTrapezoid(trapezoids[1]);
}
}
//線框模式只需使用Bresenham算法畫線就好
if (_renderState & RENDER_STATE_WIREFRAME)
{
DrawLine(Vector2i(h1), Vector2i(h2), _foreground);
DrawLine(Vector2i(h1), Vector2i(h3), _foreground);
DrawLine(Vector2i(h2), Vector2i(h3), _foreground);
}
}
首先是掃描線類,定義了掃描的起始點和步長,同時記錄了掃描線的起始點的x和y坐標以及掃描線的寬度,由于掃描線對應屏幕上的光柵,所以要注意掃描線的相關數據都是整形的
class Scanline
{
public:
Vertex _start, _step;
_INT32 _x, _y, _width;
public:
Scanline(){}
Scanline(Vertex start, Vertex step, _INT32 x, _INT32 y, _INT32 width);
};
下面的代碼是光柵器中繪制掃描線的部分,繪制的每一個像素點之前會檢查深度緩存以決定是否放棄繪制某個點,同時需要注意對插值后的uv坐標進行還原
void Device::DrawScanline(Scanline& scanline)
{
_UINT32* frameBuffer = _frameBuffer[scanline._y];
_FLOAT* zBuffer = _zBuffer[scanline._y];
//
for (auto i = 0, x = scanline._x; i < scanline._width && x < _width; i++, x++)
{
if (x >= 0 && x < _width)
{
_FLOAT deepz = scanline._start._deepz;
if (deepz >= zBuffer[x])
{
_FLOAT w = 1.0f / deepz;
zBuffer[x] = deepz;
if (_renderState & RENDER_STATE_COLOR)
{
Color color = scanline._start._color;
_INT32 R = (_INT32)(color._r * 255.0f);
_INT32 G = (_INT32)(color._g * 255.0f);
_INT32 B = (_INT32)(color._b * 255.0f);
R = (_INT32)Range((_FLOAT)R, 0.0f, 255.0f);
G = (_INT32)Range((_FLOAT)G, 0.0f, 255.0f);
B = (_INT32)Range((_FLOAT)B, 0.0f, 255.0f);
frameBuffer[x] = (R << 16) | (G << 8) | (B << 0);
}
if (_renderState & RENDER_STATE_TEXTURE)
{
//由于我們是對u/z和v/z進行插值的,所以為了得到uv坐標這里需要乘以z
_FLOAT u = scanline._start._u * w;
_FLOAT v = scanline._start._v * w;
frameBuffer[x] = ReadTexture(u, v);
}
}
}
scanline._start = scanline._start + scanline._step;
}
}