天天看點

SoftRenderer&RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

簡介

這是可能一篇沒有什麼實際作用的文章,因為沒有任何shader效果實作,整篇文章到最後,我隻實作了一個旋轉的立方體(o(╯□╰)o,好弱),和遊戲引擎渲染的萬紫千紅的3D世界顯得有很大落差,仿佛一切都回到了最初的起點(不知道有沒有人能猜出來左側的是哪部遊戲大作的截圖(*^▽^*))。

SoftRenderer&RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

不過實時渲染繁華的背後,還是這看似簡單的光栅化。今天,本人打算學習一下基本的光栅化渲染的過程,看一下怎樣把一個模型從一些頂點資料,變成最終顯示在螢幕上的圖像,也就是所謂的渲染流水線。

所謂SoftRenderer(軟渲染),就是用純代碼邏輯模拟OpenGL或者D3D渲染流水線,當然,隻是實作了冰山一角,軟渲染沒有什麼實際運用意義,但是對學習渲染原理來說應該是一個比較有效的手段了。之前開了個SoftRenderer的小坑,隻是實作了基本的3D變換,三角形圖元繪制,仿射紋理映射,最近本想寫一篇深度相關的blog的,奈何寫了一半發現好多内容需要推導計算,于是乎索性先把軟渲染的坑補完,正好這幾天把透視校正紋理映射加上了,順便修了幾個bug,基本可以運作起來了(然而我隻畫了個正方體,幀率還慘不忍睹)。一些進階的特性,比如法線貼圖,複雜光照,雙線性采樣,RT之類的就等之後再慢慢填坑啦(恩,這很有可能是個棄坑-_-)。

簡單介紹一下,項目使用的是C++,類似D3D的3D模式,左手坐标系,NDC中Z是(0,1)區間,向量為行向量,左乘矩陣,沒有第三方庫,隻用到了Windows GDI相關,工程VS2015。基本實作了模型->世界->齊次裁剪空間->裁剪->透視除法->視口映射->ZBuffer深度測試->透視校正紋理映射等功能。實作過程中參考了知乎大佬們的各種回答。本人才疏學淺,尤其是渲染這裡,很可能隻是“看起來是對的“,再加上哥們600多的近視,是以如果有錯誤,還望各位高手批評指正。

渲染流水線

《Real Time Renderering》中把渲染流水線的功能簡要描述如下:給定一個虛拟相機,一些三維物體,燈光,着色方程,貼圖等資源,最終生成一張二維圖檔的過程。所謂流水線Pipline,也就是把原本線性執行的工作,改為并行執行,這樣可以極大地提高效率。流水線的瓶頸也就是流水線中最弱的(耗時最長的)。渲染流水線在《RTR》中定義為三個基本階段,而每一個階段裡面,又分為一些具體的操作步驟,下面分别看一下。

Application(應用程式階段):字面意思即可,就是相對于渲染來說,其他的内容基本都可以定義在這個階段。比如遊戲邏輯,實體等等。個人了解為在調用DrawCall之前的階段,該階段主要在CPU上進行。

Geometry(幾何階段):主要是頂點着色器(MVP變換),裁剪,螢幕映射。處理上一階段傳遞進來的圖元和位置等資訊,計算變換位置,最終決定物體在螢幕上的哪個位置,過程中還需進行裁剪,計算一些需要傳遞給下一階段的資料。下圖是《RTR3》中定義的Gemmetry階段:

SoftRenderer&RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

Rasterizer(光栅化階段):光栅化階段是将上一階段變換投影映射後螢幕空間的頂點(包括頂點包含的各種資料),轉化為螢幕上像素的一個過程。在該階段主要進行的是三角形資料的設定,資料插值,像素着色(包括紋理采樣),Alpha測試,深度測試,模闆測試,混合。

SoftRenderer&RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

為了更好地顯示,一般會采用雙緩沖技術,即在backbuffer渲染,完成後swap到前台。

上面的流水線,可能并不是很全面,新版本的有可能有Geometry Shader,Early-Z,Tiled Based等等,而且這個東西每個廠商可能都不太一樣,但是上面的應該算是比較經典的一個流水線,并且都是必不可少的階段(曾經以為裁剪可以不用,偷一點懶,大不了性能差點,後來發現自己真是太天真)。

下面本人就來大緻實作基本的渲染流水線,本文的順序是繪制直線,三角形,MVP矩陣變換,裁剪,視口映射,深度測試,透視校正紋理采樣,是以複雜度作為順序,并非真正的渲染順序。

繪制直線

首先來研究一下怎麼繪制直線,軟渲染中每一個階段其實都有多種方法進行實作,隻不過有些是比較公認的通用方案。我這裡隻是找了一個适合我的方案。

我們顯示在螢幕上的圖像,其實就是一幅二維圖檔,而圖檔其實是離散的,換句話說,就是一個二維數組,螢幕的坐标就是數組的索引。是以,實際上我們在光栅化階段繪制的時候,直接使用int型資料即可。俗話說得好,兩點确定一條直線,那麼,這一個步驟的輸入就是兩個二維坐标,輸出就是一條直線。

實作畫線有幾種算法,DDA(最易了解,直接算斜率,有除法,不利于硬體實作,慢),Bresenham算法(沒有除法和浮點數,快),吳小林算法(帶抗鋸齒,比Bresenham慢)。這裡,我采用了Bresenham算法進行繪制:

//斜率k = dy / dx
//以斜率小于1為例,x軸方向每機關都應該繪制一個像素,x累加即可,而y需要判斷。
//誤差項errorValue = errorValue + k,一旦k > 1,errorValue = errorValue - 1,保證0 < errorValue < 1
//errorValue > 0.5時,距離y + 1點較近,因而y++,否則y不變。
int dx = x1 - x0;
int dy = y1 - y0;
float errorValue = 0;
for (int x = x0, y = y0; x <= x1; x++)
{
	DrawPixel(x, y);
	errorValue += (float)dy / dx;
	if (errorValue > 0.5)
	{
		errorValue = errorValue - 1;
		y++;
	}
}
           

上面算法的主要思想是,按照直線的一個方向,以斜率小于1為例的話,在x軸方向每次步進一個像素,y判斷離哪個像素近。通過y方向增加一個累計誤內插補點,當誤內插補點超過1了,說明y方向應該上移一個像素,否則仍然是距離目前y值近。不過上面的算法還是沒有避免掉除法的問題,我們可以通過修改一下判斷的條件,去掉除法和浮點數,并完善各種情況:

void ApcDevice::DrawLine(int x0, int y0, int x1, int y1)
{
	int dx = x1 - x0;
	int dy = y1 - y0;

	int stepx = 1;
	int stepy = 1;
	
	if (dx < 0)
	{
		stepx = -1;
		dx = -dx;
	}

	if (dy < 0)
	{
		stepy = -1;
		dy = -dy;
	}

	int dy2 = dy << 1;
	int dx2 = dx << 1;

	int x = x0;
	int y = y0;
	int errorValue;

	//改為整數計算,去掉除法
	if (dy < dx)
	{
		errorValue = dy2 - dx;
		for (int i = 0; i <= dx; i++)
		{
			DrawPixel(x, y);
			x += stepx;
			errorValue += dy2;
			if (errorValue >= 0)
			{
				errorValue -= dx2;
				y += stepy;
			}
		}
	}
	else
	{
		errorValue = dx2 - dy;
		for (int i = 0; i <= dy; i++)
		{
			DrawPixel(x, y);
			y += stepy;
			errorValue += dx2;
			if (errorValue >= 0)
			{
				errorValue -= dy2;
				x += stepx;
			}
		}
	}
}
           

那麼,我們随機在螢幕上畫兩條線的效果如下,分辨率是600*450,仔細看鋸齒還是挺嚴重的,也讓我深刻地意識到了抗鋸齒的重要性o(╯□╰)o:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

繪制三角形

邁出了最難的第一步,接下來就會好很多了。點,線,面,三個點确定一個三角形,是以我們再加一個參數,繪制一個三角形看一下。但是此處并非直接給三個點,連在一起,而是要把三角形内部填充上顔色。最簡單的填充就是掃描線填充,換句話說我們按照螢幕的y軸方向,從上向下,每一行進行繪制,直到把三角形全部填充上為止。首先要把三角形分一下類:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

我們已知的是y的方向,那麼就需要帶入三角形邊的直線方程求得x的坐标,然後在兩個點之間畫線。如果三角形是平頂的或者是平底的,那麼我們隻需要考慮兩條邊即可,但是如果是一般的三角形,我們就需要做一下拆分,把三角形劃分成一個平頂三角形和一個平底三角形,以上圖為例,三角形GHI,以H的y值帶入GI直線方程求得J的x坐标,生成GHJ和HJI兩個三角形,這兩個三角形就可以按照平頂和平底的方式進行光栅化了。

以平底三角形ABC為例,假設A(x0,y0),B(x1,y1),C(x2,y2),AB直線上随機一點(x,y),那麼直線方程如下:

(y - y1) / (x - x1) = (y0 - y1) / (x0 - x1),我們已知y(從y0到y1循環),則x值為: x = (y - y0) * (x0 - x1) /  (y0 - y1) + x1。AC邊類似得到x值,那麼循環中我們就可以根據y值得到每條掃描線左右的x值。平底和平頂三角形的繪制代碼如下:

void ApcDevice::DrawBottomFlatTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	for (int y = y0; y <= y1; y++)
	{
		int xl = (y - y1) * (x0 - x1) / (y0 - y1) + x1;
		int xr = (y - y2) * (x0 - x2) / (y0 - y2) + x2;
		DrawLine(xl, y, xr, y);
	}
}

void ApcDevice::DrawTopFlatTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	for (int y = y0; y <= y2; y++)
	{
		int xl = (y - y0) * (x2 - x0) / (y2 - y0) + x0;
		int xr = (y - y1) * (x2 - x1) / (y2 - y1) + x1;
		DrawLine(xl, y, xr, y);
	}
}
           

知道了平頂和平底,我們隻要根據拐點計算出拐點y軸對應另一邊的x值,生成新的三角形即可。但是此處我們為了代碼簡單一些,先對傳入的三個頂點進行一下排序:、

void ApcDevice::DrawTrangle(int x0, int y0, int x1, int y1, int x2, int y2)
{
	//按照y進行排序,使y0 < y1 < y2
	if (y1 < y0)
	{
		std::swap(x0, x1);
		std::swap(y0, y1);
	}
	if (y2 < y0)
	{
		std::swap(x0, x2);
		std::swap(y0, y2);
	}
	if (y2 < y1)
	{
		std::swap(x1, x2);
		std::swap(y1, y2);
	}

	if (y0 == y1)	//平頂三角形
	{
		DrawTopFlatTrangle(x0, y0, x1, y1, x2, y2);
	}
	else if (y1 == y2) //平底三角形
	{
		DrawBottomFlatTrangle(x0, y0, x1, y1, x2, y2);
	}
	else			//拆分為一個平頂三角形和一個平底三角形
	{
		//中心點為直線(x0, y0),(x2, y2)上取y1的點
		int x3 = (y1 - y0) * (x2 - x0) / (y2 - y0) + x0;
		int y3 = y1;

		//進行x排序,此處約定x2較小
		if (x1 > x3)
		{
			std::swap(x1, x3);
			std::swap(y1, y3);
		}
		DrawBottomFlatTrangle(x0, y0, x1, y1, x3, y3);
		DrawTopFlatTrangle(x1, y1, x3, y3, x2, y2);
	}
}
           

通過上面的步驟,我們就可以在螢幕上繪制一個填充好的三角形啦,如下圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

ObjectToWorld矩陣建構

下面就是3D相關的操作了,這也是軟渲染中最有意思的部分。首先就是MVP變換,具體來說就是将模型空間坐标通過ObjectToWorld矩陣(M)變換到世界空間,然後通過WorldToView矩陣(V)變換到相機空間,最後通過透視投影矩陣(P)變換到裁剪空間。上述的幾個變換都是由矩陣完成的。關于矩陣,簡單點來說就是一個二維數組,隻是為了更加友善地表達變換的過程,并且這些計算通過矩陣更加容易用硬體進行實作。下面主要用到的是矩陣的乘法,向量與矩陣的乘法,矩陣的轉置等特性。

如果把矩陣的行解釋為坐标系的基向量,那麼乘以該矩陣就相當于做了一次坐标變換,vM = w,稱之為M将v變換到w。用基向量[1,0,0]與任意矩陣M相乘,得到[m11,m12,m13],得出的結論是矩陣的每一行都可以解釋為轉化後的基向量。根據該結論,就可以通過一個期望的變換,反向構造出一個矩陣來代表這個變換。首先來看一下MVP的M,也就是模型空間轉世界空間的變換,這個變換也是分為三個子變換,分别是縮放,旋轉,平移。下面分别推導一下幾個變換的矩陣。

縮放矩陣

縮放是最簡單的變換,假設頂點為(x, y, z),縮放系數為(sx, sy, sz),那麼縮放希望的就是(sx *x, sy * y, sz * z),我們用一個矩陣來表示的話就是:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

代碼如下:

Matrix ApcDevice::GenScaleMatrix(const Vector3& v)
{
	Matrix m;
	m.Identity();
	m.value[0][0] = v.x;
	m.value[1][1] = v.y;
	m.value[2][2] = v.z;
	return m;
}
           

旋轉矩陣

接下來是旋轉矩陣,看起來有點麻煩,而且x,y,z三個方向需要單獨實作,其實又是三個矩陣相乘,這篇文章推導的比較明了,借用一張圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

到這個時候,我才意識到,哇塞,原來三角函數展開的實際應用竟然這麼重要。。。我們要表示一個旋轉,比如繞Z軸旋轉,那麼這個時候,Z軸的坐标是不會變化的,是以隻考慮X,Y軸坐标即可,比如上圖,A為原始坐标(x,y),角度為β,旋轉角度α變換到B點(x’,y'),半徑為r。此時,左右手坐标系的差别就有了,我使用的是左手坐标系,是以用左手拇指指向Z軸正方向(指向螢幕内),另外四指環繞的方向就是旋轉的正方向了。如果用右手坐标系,那麼就得換成右手了。

那麼x’= r * cos(α + β) = cos(α) * cos(β) * r - sin(α) * sin(β) * r = cos(α) * x - sin(α) * y

同理y’= r * sin(α + β) = sin(α) * cos(β) * r + cos(α) * sin(β) * r = sin(α) * x + cos(α) * y

根據上面的兩個等式,我們就可以建構出一個矩陣,Z方向不變,置為1即可,矩陣如下:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

繞X軸旋轉的矩陣:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

繞Y軸旋轉的矩陣:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

最終,這樣,每個軸上我們給定一個角度即可得到旋轉的矩陣,最終矩陣為三者相乘,繞三個軸旋轉值按一個Vector傳遞進來:

Matrix ApcDevice::GenRotationMatrix(const Vector3& rotAngle)
{
	Matrix rotX = GenRotationXMatrix(rotAngle.x);
	Matrix rotY = GenRotationYMatrix(rotAngle.y);
	Matrix rotZ = GenRotationZMatrix(rotAngle.z);
	return rotX * rotY * rotZ;
}

Matrix ApcDevice::GenRotationXMatrix(float angle)
{
	Matrix m;
	m.Identity();
	float cosValue = cos(angle);
	float sinValue = sin(angle);
	m.value[1][1] = cosValue;
	m.value[1][2] = sinValue;
	m.value[2][1] = -sinValue;
	m.value[2][2] = cosValue;
	return m;
}

Matrix ApcDevice::GenRotationYMatrix(float angle)
{
	Matrix m;
	m.Identity();
	float cosValue = cos(angle);
	float sinValue = sin(angle);
	m.value[0][0] = cosValue;
	m.value[0][2] = -sinValue;
	m.value[2][0] = sinValue;
	m.value[2][2] = cosValue;
	return m;
}

Matrix ApcDevice::GenRotationZMatrix(float angle)
{
	Matrix m;
	m.Identity();
	float cosValue = cos(angle);
	float sinValue = sin(angle);
	m.value[0][0] = cosValue;
	m.value[0][1] = sinValue;
	m.value[1][0] = -sinValue;
	m.value[1][1] = cosValue;
	return m;
}
           

上面,我們隻考慮物體繞自身軸旋轉,如果需要繞任意一點旋轉的話,我們就可以先進行平移,平移到該點,然後再旋轉,旋轉後再平移回去。即v = vTRT^-1

平移矩陣

原本而言,我們用3x3的矩陣即可表示上面兩種變換,然而平移不行,是以就需要引入一個4x4的矩陣來表達平移變換,比如一個頂點(x,y,z)我們希望它平移(tx,ty,tz)距離,那麼實際上就是(x + tx,y + ty,z + tz)。使用矩陣來表示的話就是:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

而如果用4x4矩陣,Vector3顯然也不夠用了,為了能達到上述的變換,我們就需要給Vector增加一個w次元,w = 1時,Vector表示點,與上述矩陣相乘後得到變換的結果,而w = 0時,Vector表示向量,上述變換的結果仍然是Vector自身,平移變換對方向變換不生效。

平移矩陣的代碼如下:

Matrix ApcDevice::GenTranslateMatrix(const Vector3& v)
{
	Matrix m;
	m.Identity();
	m.value[3][0] = v.x;
	m.value[3][1] = v.y;
	m.value[3][2] = v.z;
	return m;
}
           

ObjectToWorld矩陣整合

經過上面的步驟,我們得到了縮放矩陣,旋轉矩陣,平移矩陣,最終将三者使用矩陣乘法相乘即可得到完整的ObjectToWorld矩陣,由于我們采用了行向量表示坐标,是以相當于左乘,即我們用vSRT的順序進行變換。

我們可以根據矩陣連乘結合律把幾個變換通過矩陣相乘的方法,先計算出一個結果矩陣再用該矩陣去變換頂點,這有一個很大的好處,就是我們可以逐對象計算一次變換整體的變換矩陣,然後這個對象的所有的頂點都應用這個變換。這樣就把變換的時間從n次MVP計算+n次矩陣與頂點相乘變成了1次矩陣MVP計算+n次矩陣與頂點相乘,而且可以避免浮點計算誤差(嘗試了一下逐圖元計算變換矩陣,最後正方形邊界對不上,改為統一計算後效果正常)。我們不需要一些特殊的效果(固定管線),是以我們直接把這個SRT矩陣與後續的VP矩陣相乘,結果再來進行坐标的變換。

WorldToCamera矩陣建構 

上一階段,我們把頂點從模型空間轉換到了世界空間,但是有一個很重要的問題,相機才是我們觀察世界的視窗,後續的投影,裁剪,深度等如果都在世界空間做的話就太複雜了,如果我們把這些操作的坐标原點改為相機,就會大大降低後續操作的複雜性。是以,下一步就是如何将一個世界空間的對象轉化到相機空間。要定義一個相機,首先要有相機位置,還要有相機看的方向,一種方法是給出相機的旋轉角度,也就是Raw,Pitch,Roll這三個值,如下圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

另一種方案就是給一個相機注視點以及一個控制相機Y軸方向的向量,也就是UVN相機模型,UVN比較容易定義相機的朝向,類似LookAt的功能,是以這裡我們采用這種方式定義我們的相機位置和朝向。給定了相機位置和注視點位置,我們就能求得視方向向量N,然後我們根據給定的上向量V(隻是輸入臨時用的,不是最終的V,輸入的V在VN平面沒有權重,因為N已經确定了視方向,隻有UV平面上可能有權重,是以需要重新計算一個僅影響Roll的角),通過向量叉乘得到一個Cross(N,V)得到右向量U,最終我們再用Cross(N,U)得到真正的V,三者都需要Normalize。這樣,我們就建構出了相機所在的空間基準坐标系。

我們要的是WorldToCamera的變換,不過,對一個點做一個變換,相當于對其所在坐标系做逆變換,是以我們隻要求出整體變換的逆矩陣即可。比如上面的變換稱之為WTC,上面的變換包括一個旋轉R和平移T,那麼我們要求的WTC^-1 (表示逆)=(RT)^-1 = (T^-1)( R^-1)。我們拆開來看:

先是旋轉矩陣的逆,其實上面的計算,我們建構的UVN就是R矩陣了(把相機轉換到世界空間,但是在第四行沒有分量,換句話說就是3X3的矩陣,沒有位移,也沒有縮放,那麼就隻有旋轉),下面一步就是求R的逆。矩陣正常求逆的運算是很費的,是以一般來說要避免直接求逆,因為3X3的旋轉矩陣其實是一個正交矩陣(各行各列都是機關向量,并且兩兩正交,可以把上一節的旋轉矩陣每個看一遍,抽出左上角3X3部分)。正交矩陣的重要性質就是MM^T (轉置)= E(機關矩陣),進一步推導就是M ^ T = M ^ -1,是以上面的旋轉矩陣的逆實際上就是它的轉置。轉置的話,我們隻需要沿着對角線把資料互換一下,計算量比起求逆要小得多。 

接下來是平移矩陣的逆,這個其實不需要去推導,比如向量按照(tx,ty,tz)進行了平移變換,那麼對它的逆變換其實就是取反(-tx,-ty,-tz)。

最終兩個矩陣以及相乘結果如下,其中T = (tx,ty,tz),UVN同理:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

我們直接結果建構最終的矩陣,避免多一次矩陣運算,代碼如下:

Matrix ApcDevice::GenCameraMatrix(const Vector3& eyePos, const Vector3& lookPos, const Vector3& upAxis)
{
	Vector3 lookDir = lookPos - eyePos;
	lookDir.Normalize();

	Vector3 rightDir = Vector3::Cross(upAxis, lookDir);
	rightDir.Normalize();

	Vector3 upDir = Vector3::Cross(lookDir, rightDir);
	upDir.Normalize();

	//建構一個坐标系,将vector轉化到該坐标系,相當于對坐标系進行逆變換
	//C = RT,C^-1 = (RT)^-1 = (T^-1) * (R^-1),Translate矩陣逆矩陣直接對x,y,z取反即可;R矩陣為正交矩陣,故T^-1 = Transpose(T)
	//最終Camera矩陣為(T^-1) * Transpose(T),此處可以直接給出矩陣乘法後的結果,減少運作時計算
	float transX = -Vector3::Dot(rightDir, eyePos);
	float transY = -Vector3::Dot(upDir, eyePos);
	float transZ = -Vector3::Dot(lookDir, eyePos);
	Matrix m;
	m.value[0][0] = rightDir.x;  m.value[0][1] = upDir.x;  m.value[0][2] = lookDir.x;  m.value[0][3] = 0;
	m.value[1][0] = rightDir.y;	 m.value[1][1] = upDir.y;  m.value[1][2] = lookDir.y;  m.value[1][3] = 0;
	m.value[2][0] = rightDir.z;  m.value[2][1] = upDir.z;  m.value[2][2] = lookDir.z;  m.value[2][3] = 0;
	m.value[3][0] = transX;		 m.value[3][1] = transY;   m.value[3][2] = transZ;	   m.value[3][3] = 1;
	return m;
}
           

透視投影矩陣建構

接下來的投影階段是3D向2D轉換的一個重要的步驟(并不是這一步就轉),同時也是後續CVV裁剪,ZBuffer,透視紋理校正的基礎,這裡我們暫且不考慮正交投影,直接來看透視投影。上文我們提到,為了更好地變換,用4X4矩陣,進而引入齊次坐标的概念,但是實際上,齊次坐标系真正的作用在于透視投影變換。

透視投影的主要知識點在于三角形相似以及小孔呈像,透視投影實作的就是一種“近大遠小”的效果,其實投影後的大小(x,y坐标)也剛好就和1/Z呈線性關系。首先看下面一張圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

上圖是一個視錐體的截面圖(隻看x,z方向),P為空間中一點(x,y,z),那麼它在近裁剪面處的投影坐标假設為P’(x',y',z’),理論上來說,呈像的面應該在眼睛後方才更符合真正的小孔呈像原理,但是那樣會增加複雜度,沒必要額外引入一個負号(此處有一個裁剪的注意要點,下文再說),隻考慮三角形相似即可。即三角形EAP’相似于三角形EGP,我們可以得到兩個等式:

x’/ x = z’/ z => x’= xz’/ z  

y’/ y = z’/ z => y’= yz’/ z

由于投影面就是近裁剪面,那麼近裁剪面是我們可以定義的,我們設其為N,遠裁剪面為F,那麼實際上最終的投影坐标就是:

(Nx/z,Ny/z,N)。

投影後的Z坐标,實際上已經失去作用了,隻用N表示就可以了,但是這個每個頂點都一樣,每個頂點帶一個的話簡直是暴殄天物,浪費了一個珍貴的次元,是以這個Z值會被存儲一個用于後續深度測試,透視校正紋理映射的變換後的Z值。

這個Z值,還是比較有說道的。在透視投影變換之前,我們的Z實際上是相機空間的Z值,直接把這個Z存下來也無可厚非,但是後續計算會比較麻煩,畢竟沒有一個統一的标準。既然我們有了遠近裁剪面,有了Z值的上下限,我們就可以把這個Z值映射到[0,1]區間,即當在近裁剪面時,Z值為0,遠裁剪面時,Z值為1(暫時不考慮reverse-z的情況)。首先,能想到的最簡單的映射方法就是depth = (Z(eye) - N)/ F - N。but,這種方案是不正确的(需要參考下文關于光栅化資料插值的内容,此處先給出結論,我認為這個1/z在光栅化階段解釋更為合适):透視投影變換之後,在螢幕空間進行插值的資料,與Z值不成正比,而是與1/Z成正比。是以,我們需要一個表達式,可以使Z = N時,depth = 0,Z = F時,depth = 1,并且需要有一個z作為分母,可以寫成(az + b)/z,帶入上述兩個條件:

(N * a  + b) / N = 0   =>  b = -an

(F * a  +  b) / F = 0   =>   aF + b = F => aF - aN = F

進而得到: a = F / (F - N) b = NF / (N - F)

最終其實視錐體被變換為NDC,但是實際上我們一般是先不進行透視除法,那麼就稱之為CVV(此處不要過度糾結這兩個概念,下文再去解釋)過程如下圖(DX文檔附圖)所示(下圖是DX模式,左手系,NDC中Z區間是0到1,也是本文使用的模式;GL的話NDC中Z是-1到1,而且是右手系,二者最終推導出的透視投影矩陣是有差異的),主要思想就都是映射,把一個區間映射到另一個區間:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

我們已經把Z值,進行了映射,秉承着映射大法好的觀點,我們再把X和Y進行一下映射,上面我們已經計算出投影後的坐标值是(Nx/z,Ny/z,N)。假設近裁剪面上下分别為U,B,左右分别為L,R,我們要把原始的區間映射到[-1,1]區間。我們以X軸為例進行推導,最終X投影點映射到NDC的坐标假設為Xndc,那麼映射公式如下:

Xndc - (-1) / (1 - (-1)) = (XN / Z - L) / (R - L)

計算該公式得到Xndc = 2XN / Z(R - L) - (R + L)/ (R - L)

同理Yndc  = 2YN / Z(T - B) - (T + B) / (T - B)

這樣,我們就可以得到了XYZ三個方向在NDC空間的坐标。

下面在來解釋一下NDC和CVV,所謂NDC,全稱為Normalized Device Coordinates,也就是标準裝置空間,為何要引入這樣一個空間呢,主要在于我們使用不同的裝置,分辨率可能都不一樣,實際在寫shader的時候,沒辦法根據分辨率進行調整,而通過這樣一個空間,把x,y映射到(-1,1)區間,z映射到(0,1)區間(OpenGL是(-1,1)),在下一步螢幕坐标映射時再根據螢幕分辨率生成像素真正應該在的位置,這樣可以省掉很多裝置适配的問題,讓我們在寫shader的時候一般不需要考慮螢幕分辨率的問題(有時候有,主要是全屏後處理時螢幕寬高的比例,我之前在螢幕水波紋效果中實作就遇到了這樣的問題)。

再來看一下CVV,CVV全稱為Canonical View Volume,即規則觀察體。其實上面的變換最終的坐标應該是NDC的,但是為了更友善地做一些其他的操作,主要是CVV裁剪,引入了一個新的空間,這個空間主要是沒有NDC空間的坐标沒進行除以w計算,也就是說CVV空間的頂點還是齊次空間下的,除了w之後才會變為NDC空間,兩者的差距主要是是否除以了w。個人了解:CVV隻是用齊次坐标系表示了的NDC(如果我的了解不正确,還希望您及時指出我的錯誤)。

關于CVV裁剪,下文再講,我們還是回到透視投影矩陣:

我們既然要CVV空間,也就是齊次裁剪空間下的頂點,是以我們剛好可以把上文得到的投影點坐标的每個元素的除以Z變換成W分量的Z,然後XYZ分量下的除以Z就可以去掉了,也就是說用齊次空間表示一下最終的投影點坐标:

P’(NX,NY,aZ + b,Z)帶入上面的推導結果得到透視投影矩陣:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

上面的矩陣是一個通用的矩陣,但是實際上我們絕大多數情況用的矩陣都是一個特殊情況的矩陣,也就是我們的相機剛好在視錐體中間,上下左右對稱,那麼R和L對稱,T和B對稱,兩者相加都等于0,而R-L和T-B我們就可以用一個寬度和高度來表示,簡化後的矩陣如下:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

下面我們再考慮一下怎樣更加優雅地表示這個矩陣(換句話說就是參考一下DX和GL真正的接口)。我們一般來說,給定一個

FOV角度,N近裁剪面,F遠裁剪面,Aspect螢幕寬高比即可,如下圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

那麼BF也就是的高度就是tan(0.5*fov)* N 最終的H = 2 tan(0.5fov)*N,最終的W = Aspect * H。帶入上述矩陣:

2N/H = 2N/2tan(0.5fov)N = 1/tan(0.5fov) = cot(0.5fov)

2N/W = 1/(Aspect * tan(0.5fov)) = cot(0.5fov)/Aspect

最終矩陣結果如下,暫且還是以tan表示:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

代碼如下:

Matrix ApcDevice::GenProjectionMatrix(float fov, float aspect, float nearPanel, float farPanel)
{
	float tanValue = tan(0.5f * fov * 3.1415 / 180);

	Matrix proj;
	proj.value[0][0] = 1.0f / (tanValue * aspect);
	proj.value[1][1] = 1.0f / (tanValue);
	proj.value[2][2] = farPanel / (farPanel - nearPanel);
	proj.value[3][2] = -nearPanel * farPanel / (farPanel - nearPanel);
	proj.value[2][3] = 1;
	return proj;
}
           

這裡,我們隻是進行了透視投影變換的第一步,将頂點轉化到齊次裁剪空間,因為并沒有進行透視除法,是以還沒有所謂的投影。第二步是透視除法,但是中間一般還會穿插一步,CVV裁剪。

啊,終于寫完了透視投影矩陣的變換,實際上這個矩陣結果隻有幾個數,然而背後的數學推導還是比較複雜的。有了P矩陣,我們最終用來變換的矩陣就都完成了。

CVV裁剪

經過了透視變換,坐标被變換到CVV空間,此時仍然是齊次坐标,我們正常應該是判斷在裁剪的立方體内,不過齊次坐标我們也就是直接比較xyz值和w的值即可,DX模式的話,z需要比較0和w。這個是非常重要的,因為我們預設為了友善是把投影平面放到了眼睛前面,但是真的有在投影平面後面的東西,如果不剔除z<0的内容,就會導緻這一部分按照不對的透視公式進行計算導緻結果錯誤。而且更重要的一點在于,相機空間z = 0的時候(也就是齊次空間的w = 0)的這種情況,在我們透視除法的時候會有除0的問題。是以要把這個剔除掉。

比如一個齊次空間的頂點,我們可以按照上述方式判斷其是否在CVV内:

inline bool ApcDevice::SimpleCVVCullCheck(const Vertex& vertex)
{
	float w = vertex.pos.w;
	if (vertex.pos.x < -w || vertex.pos.x > w)
		return true;
	if (vertex.pos.y < -w || vertex.pos.y > w)
		return true;
	if (vertex.pos.z < 0.0f || vertex.pos.z > w)
		return true;
	return false;
}
           

CVV裁剪個人感覺是一個比較有争議的地方,現代的GPU到底如何去做裁剪,我不敢妄加推測,看了知乎上大佬們的讨論,也是分為幾個派别。有認為裁剪的,有人為隻剔除不裁剪的。不過個人倒比較贊同,重新建構一個三角形對于GPU來說還不如把整個三角形都畫了好,畢竟實際運用時,三角形的密度很大,面積很小,都繪制了也要比裁剪可能還省。對于CVV中比較好處理的主要在于我們可以在透視除法前就把完全不可見的三角形直接剔除掉。是以我隻實作了最簡單的三頂點均不在CVV内剔除的方案(好吧,為偷懶找了個理由o(╯□╰)o)。

實作了CVV裁剪,其實還是蠻爽的,尤其是本身沒有場景管理,視錐體裁剪的話,送出上來的所有内容都要繪制,如果本身不可見的話,那直接就咔掉了。測試的話,直接渲染立方體,幀率25左右,立方體在CVV外不開裁剪,幀率100多些(有視口範圍判斷),立方體在CVV外開裁剪,幀率999+直接爆表 。

透視除法與螢幕坐标映射

經過了透視投影變換,CVV裁剪,現在我們的頂點坐标都在齊次裁剪空間,下一步就是真正地進行透視除法了,經過了這一步才真正算是完成了透視投影變換。其實這個操作比較簡單,因為計算在透視投影矩陣的建構中我們都推導過了,乘過Project矩陣的頂點,w坐标就不會是預設的1了。因為我們把Z值存了進去,此時我們将三個分量都除以Z,就得到了透視變換後的NDC坐标了。

馬上就可以和我們之前進行的在螢幕上繪制三角形聯系起來了,中間隻差了一步,那就是螢幕坐标映射。上文介紹過,NDC的作用就是為了讓我們計算時不需要考慮螢幕分辨率相關的問題,因為DX或者GL替我們做了,軟渲染的話,我們就需要自己做這一步。

我們建立視窗的時候,會給一個視窗的寬度和高度(RT類似),既然我們得到了NDC空間的坐标值了,并且知道了螢幕的長和寬(分辨率),那麼,是時候進行一波映射了。映射大法好啊!

NDC是(-1,1)區間(現在暫時隻考慮X,Y),我們要把它映射到螢幕的(0,width)和(0,height)區間即可。先看X方向:首先,我們從(-1,1)區間映射到(0,1)區間,也就是(v.x / v.w + 1)* 0.5 * deviceWidth。Y方向,螢幕實際的坐标是左上角為(0,0)點,與我們的NDC是反過來的,是以映射到(0,1)區間後,還需要反向一下,改為(1-screendCoord)* deviceHeight。代碼如下,進行了透視除法&螢幕空間映射:

float reciprocalW = 1.0f / v.w;
float x = (v.x * reciprocalW + 1.0f) * 0.5f * deviceWidth;
float y = (1.0f - v.y * reciprocalW) * 0.5f * deviceHeight;
           

這裡的x,y就是經過了上述所有變換後,最終在螢幕上的坐标點。下面我們整合一下整個3D變換的過程,最終螢幕坐标v' = vFinalMatrix => vMVP => vSRTVP => vSRxRyRzTVP,代碼如下:

Matrix ApcDevice::GenMVPMatrix()
{
	Matrix scaleM = GenScaleMatrix(Vector3(1.0f, 1.0f, 1.0f));
	Matrix rotM = GenRotationMatrix(Vector3(0, 0, 0));
	Matrix transM = GenTranslateMatrix(Vector3(0, 0, 0));
	Matrix worldM = scaleM * rotM * transM;
	Matrix cameraM = GenCameraMatrix(Vector3(0, 0, -5), Vector3(0, 0, 0), Vector3(0, 1, 0));
	Matrix projM = GenProjectionMatrix(60.0f, (float)deviceWidth / deviceHeight, 0.1f, 30.0f);

	return worldM * cameraM * projM;
}

void ApcDevice::DrawTrangle3D(const Vector3& v1, const Vector3& v2, const Vector3& v3, const Matrix& mvp)
{
	Vector3 vt1 = mvp.MultiplyVector3(v1);
	Vector3 vt2 = mvp.MultiplyVector3(v2);
	Vector3 vt3 = mvp.MultiplyVector3(v3);

	Vector3 vs1 = GetScreenCoord(vt1);
	Vector3 vs2 = GetScreenCoord(vt2);
	Vector3 vs3 = GetScreenCoord(vt3);

	DrawTrangle(vs1.x, vs1.y, vs2.x, vs2.y, vs3.x, vs3.y);
}
           

我們把MVP的計算整個抽取出來,每個對象計算一次即可,對象所有的三角形運用同一個MVP變換,即每個三角形逐頂點與MVP矩陣相乘,然後進行視口映射即可。這樣,我們就得到了一個可以變換的三角形(雖然看起來和上面一樣,然而這的确是一個有故事的三角形,因為他不是直接顯示在螢幕上的,而是曆經了無數次計算,才顯示到了螢幕上):

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

來一張動圖,應用了旋轉和平移變換(有故事的三角形自然多了一些能力,比如移動,旋轉,縮放,随着相機位置移動的近大遠小效應):

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

光栅化資料插值與仿射紋理映射

直到目前為止,我們雖然可以在螢幕上看到三角形了,并且可以運用各種變換,但是我們的三角形沒有任何其他資料,僅有一個位置資訊,這是十分令人不爽的,尤其是我這個視覺動物,憋了半天終于畫出了個三角形,然而還木有顔色,簡直是不能忍了!我們要給它加點料!要有更好的表現,我們就要給頂點加一些屬性,最常見的,也是最容易的,就是顔色啦。我們給每個頂點增加一個頂點色,存儲在頂點中。but,我們這時候應該意識到一個問題,我們的三角形隻有三個點,而最終顯示在螢幕上的可是無數個像素啊,其中的資料要怎麼樣得到呢?

比如我們正常寫shader的時候,經常會定義一個v2f之類的結構體,用來從vertex階段傳遞到fragment階段,vertex階段我們隻考慮逐頂點計算的值就可以了,傳遞v2f,到fragment階段,自動就可以在輸入時取到每個v2f在fragment階段的值,這個資料實際上是渲染管線幫我們自動處理了。所運用到的知識點其實也是非常簡單的,就是插值(Lerp)。

來看一維的插值代碼,也就是我們經常用的Lerp函數:

float LerpFloat(float v1, float v2, float t){ return v1 + (v2 - v1) * t;}
           

其實非常簡單,我們給一個(0,1)的插值控制函數,就可以完成從v1,v2之間的插值了,當t=0時,為v1,當t=1時為v2。

那麼,在三角形設定好之後,三個頂點的資料是一定的,接下來要從上到下繪制掃描線,我們每次要繪制掃描線的時候,首先要獲得掃描線兩側端點的資料值,掃描線的兩側端點的值在我們求解方程的時候可以得到,也就能求出該點在端點所在的邊所處的值,此處是從上到下,那麼我們就用y作為插值系數,以即每一點的t = (y - y0)/ (y2 - y0),然後我們就可以用這個系數去在頂點和底點兩個點之間插值得到目前線上掃描線起始點和結束點的顔色值。掃描線本身也是同理,已知左右兩點的顔色值,每次前進一個像素,都可以求出目前t = (x - x0) / (x1 - x0)作為插值系數。比如我們給上面的三角形增加一個頂點色,通過插值就可以得到如下的效果:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

有了顔色,我們的三角形就好看了不少。需求總是有的,能不能再好看一點呢?既然代碼寫不出來,那就貼張圖上去吧!(我想這應該也是前輩們發明紋理映射的初衷吧)。還是同一個問題,我們隻有頂點資料,也就是美術同學展uv得到的坐标值,存在頂點中。像素上全靠插值,那麼要想貼上一張圖,我們應該有的其實是這一個像素點應該采樣的紋理坐标也就是uv值,還是一樣的思路,我們補全插值的計算和頂點上的uv資料。然後用windows自帶的功能讀入一張bmp貼圖,然後建構一個二維數組,把這個貼圖的每個像素逐漸拷入數組就可以了。采樣時,我們計算出uv值,然後依然是映射大法好,因為uv是(0,1)區間,我們把這個區間映射到像素數組的大小,然後就可以用這個index去紋理數組中采樣該點的顔色了。其實在這一步也可以做一點小文章,比如傳過來的uv是非(0,1)區間的,那麼邊界的顔色怎麼給,如果我們直接截斷,那麼就是clamp,也可以取餘數那就是repeat,還可以實作mirror等模式。這裡我就直接Clamp了。下面是Texture類中兩個主要的函數:

void Texture::LoadTexture(const char* path)
{
	HBITMAP bitmap = (HBITMAP)LoadImage(NULL, path, IMAGE_BITMAP, width, height, LR_LOADFROMFILE);
	
	HDC hdc = CreateCompatibleDC(NULL);
	SelectObject(hdc, bitmap);
	
	for (int i = 0; i < width; i++)
	{
		for (int j = 0; j < height; j++)
		{
			COLORREF color = GetPixel(hdc, i, j);
			int r = color % 256;
			int g = (color >> 8) % 256;
			int b = (color >> 16) % 256;
			Color c((float)r / 256, (float)g / 256, (float)b / 256, 1);
			textureData[i][j] = c;
		}
	}
}

Color Texture::Sample(float u, float v)
{
	u = Clamp(0, 1.0f, u);
	v = Clamp(0, 1.0f, v);
	//暫時直接采用clamp01的方式采樣
	int intu = width * u;
	int intv = height * v;
	return textureData[intu][intv];
}
           

搞一個自己的頭像試一下紋理映射:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

看起來好多了,是不是大功告成了呢?其實并不是,因為這一節忽略了一個重要的問題,導緻我們所有的插值其實都是錯誤的,上面看起來沒有問題,是因為僅僅看起來可能是對的。

1/Z的問題與透視校正紋理映射

我們把上面的面片,沿着Y軸旋轉45度,再看一下效果:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

哦,偉大的蠍同志腫麼變成了小短腿。。。簡直玷污了我的偶像(我得趕快改好)。如果我們換一個貼圖,那麼這個問題将暴露得更加明顯:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

這是個什麼鬼。。。我貼上去的可是一個方方正正的網格貼圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

那麼,問題應該比較明确了,就出在透視上。來看一張圖解釋一下上面的現象:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

右側的三角形是我直接複制過去的,唯一的差別就在于左側CFE平面平行于近裁剪面,而右側把CFE整個一條線拉歪,使C接近近裁剪面。二者在最終投影在近裁剪面的位置完全相同,但是實際上在三維空間位置是相差甚遠的。

先祭出兩篇大佬的推導:一篇幾何證明,一篇代數證明。我數學不好,隻能膜拜這些大佬了。主要證明的就是在投影空間的值與1/z成正比。

我們存在頂點中的資料,頂點顔色,頂點上uv的坐标等内容,其實都是在模型空間下制作的,換句話說,這些值實際上應該在模型空間下進行插值計算,但是投影是一個損失次元的變換,我們單純地用最終二維螢幕上的位置距離去插值,如果Z全都一緻,那沒有影響,在投影平面均勻變換的值,對應相機空間(推回模型空間也一樣)也是均勻變換的。但是像右圖,在投影平面兩段相同距離對應相機空間的距離就不同了。

我們在推導投影矩陣的時候,得到過投影點的坐标(Nx/z,Ny/z,N),換句話說,投影後的X’本身就是與1/Z呈線性關系的,然後我們還知道uv坐标與x呈線性關系,實際上就是uv坐标與1/z呈線性關系,那麼通過這樣一個方式,我們在三角形進行設定時,強行把uv除以一個z,那麼此時uv就變成了uv/z,這個值是與投影後的nx/z呈線性關系的,也就是說我們可以在螢幕空間根據距離進行線性插值得到一點上的确切的uv/z值。不過還有一個問題,我們得到了uv/z,還需要把uv還原,也就是得到uv,我們還需要再求出目前點的1/z值,這個值被我們存在了z坐标上,我們在每個點采樣的時候,再把z乘回去,就得到了這一點真正的uv坐标值(最後除以z和w其實效果差不多,兩者其實也是線性關系)。

inline void ApcDevice::PrepareRasterization(Vertex& vertex)
{
	//透視除法&視口映射
	//齊次坐标轉化,除以w,然後從-1,1區間轉化到0,1區間,+ 1然後/2 再乘以螢幕長寬
	float reciprocalW = 1.0f / vertex.pos.w;
	vertex.pos.x = (vertex.pos.x * reciprocalW + 1.0f) * 0.5f * deviceWidth;
	vertex.pos.y = (1.0f - vertex.pos.y * reciprocalW) * 0.5f * deviceHeight;
	//将其他資料轉化為1/z
	vertex.pos.z *= reciprocalW;
	vertex.u *= vertex.pos.z;
	vertex.v *= vertex.pos.z;
}
           

在采樣時,使用了:

int errorValue = dy2 - dx;
for (int i = 0; i <= dx; i++)
{
	float t = (x - x0) / (x1 - x0);
	float z = Vertex::LerpFloat(v0.pos.z, v1.pos.z, t);
	float realz = 1.0f / z;
	float u = Vertex::LerpFloat(v0.u, v1.u, t);
	float v = Vertex::LerpFloat(v0.v, v1.v, t);	
	Color c = tex->Sample(u * realz, v * realz);
	DrawPixel(x, y, c);
	x += stepx;
	errorValue += dy2;
	if (errorValue >= 0)
	{
		errorValue -= dx2;
		y += stepy;
	}
}
           

經過透視校正紋理采樣後的效果:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

logo的效果也正常啦:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

ZBuffer算法

人的需求總是無限的,貼上了圖,還是感覺不夠爽,畢竟不是一個立體的東西,隻是個面片,下面我決定搞個模型進來。不過我不打算引入第三方庫,導入fbx那不是軟渲染幹的事兒了,這個東西在opengl玩更好。是以我手撸了個立方體資料,直接寫個頂點緩存,每個點加個uv資料。然後坐等我的立方體出現:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

額,好像。。。哪裡不太對。。。立方體面之間的遮擋關系錯亂了。換句話說,我們沒有深度測試,沒有辦法保證立方體面渲染的順序,我們需要的是像素精度的深度保證。下面來加一個ZBuffer。

其實ZBuffer的思想比較簡單,就是在逐像素增加一個緩存,每次繪制的時候,把目前深度也存儲進這個buffer,下次再繪制該像素的時候,先判斷一下目前像素的z值,如果比該值小或相等的話,說明離得更近(僅考慮ZTest LEqual)。這種情況下就可以更新目前像素點的顔色值,并且可以選擇更新深度緩存。

我們先申請一塊螢幕大小的float類型記憶體:

zBuffer = new float*[deviceHeight];
for (int i = 0; i < deviceHeight; i++)
{
	zBuffer[i] = new float[deviceWidth];
}
           

然後每次渲染之前,除掉ClearColorBuffer外,還要把DepthBuffer也Clear掉:

void ApcDevice::Clear()
{
	BitBlt(screenHDC, 0, 0, deviceWidth, deviceHeight, NULL, NULL, NULL, BLACKNESS);
	//ClearZ
	for (int i = 0; i < deviceHeight; i++)
	{
		for (int j = 0; j < deviceWidth; j++)
		{
			zBuffer[i][j] = 0.0f;
		}
	}
}
           

每次繪制時進行深度判斷,深層檢測失敗不進行繪制,深層檢測成功此處預設開啟ZWrite:

bool ApcDevice::ZTestAndWrite(int x, int y, float depth)
{
	//上面隻進行了簡單CVV剔除,是以還是有可能有超限制的點,此處增加判斷
	if (x >= 0 && x < deviceWidth && y >= 0 && y < deviceHeight)
	{
		if (zBuffer[y][x] <= depth)
		{
			zBuffer[y][x] = depth;
			return true;
		}
	}
	return false;
}
           

有了深度緩存,我們的立方體就完整啦:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

深度緩存,我采用的是1/Z,是以上面的深度預設為0表示無限遠。為了更好的效率,我的ZTest實際上放在了計算掃描線時每次插值計算出Z之後立刻就進行深層檢測,其實類似于Early-Z,而不是實際上真正渲染時的ZCheck,因為我沒有考慮Alpha Test的情況,也不需要考慮分支的問題,直接Cull掉是性能最好的。

int errorValue = dy2 - dx;
for (int i = 0; i <= dx; i++)
{
	float t = (x - x0) / (x1 - x0);
	float z = Vertex::LerpFloat(v0.pos.z, v1.pos.z, t);
	float realz = 1.0f / z;
	if (ZTestAndWrite(x, y, realz))
	{
		float u = Vertex::LerpFloat(v0.u, v1.u, t);
		float v = Vertex::LerpFloat(v0.v, v1.v, t);
		//Color c = Color::Lerp(v0.color, v1.color, t);
		Color c = tex->Sample(u * realz, v * realz);
		DrawPixel(x, y, c);
	}

	x += stepx;
	errorValue += dy2;
	if (errorValue >= 0)
	{
		errorValue -= dx2;
		y += stepy;
	}
}
           

關于深度,其實還有很多可以玩的,不過這麼好玩的東西,還是另起一篇blog吧。

最後,再來一張動圖:

SoftRenderer&amp;RenderPipeline(從迷你光栅化軟渲染器的實作看渲染流水線)

總結

本文主要實作了基本的光栅化渲染器的一些常見的特性,MVP矩陣變換,簡單CVV剔除,視口映射,光栅化,透視校正紋理采樣等等。目前還有基本的光照,背面剔除,線框渲染,相機控制等幾個是我打算加的。有些算法肯定很古老,而且GPU的具體實作我不敢妄加揣測,填軟渲染的坑主要是為了學習的目的,加深一下對渲染知識的了解。其實實作的過程還是蠻有意思的(有點找到了幾年前寫了第一個指令行版本的2048的那種樂趣,當時自己玩了半宿,一邊玩一邊手舞足蹈,室友以為我瘋了呢),很久不寫的C++又溫習了一下,再一個方面就是需要考慮優化了,讓我清晰地意識到逐像素計算是真的很費很費,面片貼臉的時候基本就跑不動啦!!!項目從開始寫的時候,隻有一個三角形沒經過變換,沒有采樣,FPS上百,逐漸增加特性之後幀率一度掉到了個位數,後來稍微優化了一下,穩定在了20-25幀,還是有很大的優化的空間的。

代碼的話直接開源吧,第一次用Git,如果能您賞個星星什麼的就更好啦。本人才疏學淺,如果您發現了什麼問題,還望批評指正。

繼續閱讀