書上和網上有很多透視變換的例子,但是其缺點是只討論一種情況,無法將不同的情況關聯(lián)起來,因此整理一篇文章,希望可以把透視變換的相關東西都聯(lián)系起來。
透視變換
透視變換,就是用一個矩陣(透視變換矩陣)將整個場景轉換到齊次裁剪空間之中。所謂的齊次裁剪空間,是一個特殊的空間,它的作用就是讓渲染管線可以對這個空間中一定范圍內的物體保留,超出范圍的物體剔除,如果有截斷表面的情況,還要生成新的頂點數據。
齊次裁剪空間通常有兩種,兩種齊次裁剪空間的x和y坐標范圍都是[-1,1],但是z坐標的范圍不同,一種是[0,1],一種是[-1,1]。很多書上,網上的教程都會說DX的齊次裁剪空間是[0,1]的那種,而OpenGL的裁剪空間是[-1,1]的那種。不過到底事實是什么樣的,我們還得試過之后才知道。
為了在計算機中模擬人眼看東西的狀態(tài),我們想出了這樣的一個模型:把人眼固定在相機空間的原點,然后從原點定義一個四棱錐,表示人眼可以看到的范圍,就像下面這張圖一樣:
這個四棱錐就被稱為視景體。除此之外,對于離眼睛太近的物體我們看不到,太遠的物體同樣也看不到,所以我們可以選取一定范圍內的物體顯示出來,這樣可以節(jié)省渲染時間。那么多近的物體不顯示,多遠的物體不顯示呢?這個沒有定論,經常使用n和f表示這兩個距離,比n近的物體不顯示,比f還遠的物體不顯示。這兩個面就把視景體截斷成一個四棱臺,這個四棱臺里的物體會被繪制,而在外面的不會。
接著,我們就可以來推導這個透視變換矩陣了。此時我們是在相機中間中,并且假設相機空間是一個右手坐標系。
水平視場角
下圖是從視景體的上方垂直看下去的圖,就像是三視圖一樣。圖中,三角形的頂邊就是投影平面。
參數說明:為了計算方便,我們取特殊的投影平面,它的x坐標區(qū)間是[-1,1],這個坐標區(qū)間剛好可以直接當成齊次裁剪空間的坐標。如圖中所示,投影平面距離原點的距離為e,注意這是距離,不是投影平面的z坐標,這個e有時也被稱為相機的焦距。這樣,左錐面和右錐面形成的夾角為xfov,稱為水平視場角。
水平視場角,英文名是horizontal angle of view,但是通常,我們會把視場角和視野混用,視野的英文是field of view,所以我就用xfov(x-axis field of view)來表示水平視場角。
可以看到,距離e滿足
再來看看垂直方向上:
垂直方向與水平方向類似,投影平面的縱橫比(寬除以高)用aspect表示,這個比值通常是16:9,就是我們顯示器的寬高比。所以,投影平面的y坐標范圍為[-1/aspect, 1/aspect]。如圖中所示,垂直視場角
計算投影后的x坐標
原始的頂點是v,投影之后的頂點是v',根據相似三角形原理,我們可以列出下面的等式:
求解x'得:
接著計算y'的值。
如上圖所示,同樣使用相似三角形的公式來計算:
于是
到這里,y'的計算還沒有結束,最關鍵的一步來了!上述的推導成立的前提是,y的投影面是[-1/aspect, 1/aspect],而齊次裁剪空間中,y的取值范圍必須是[-1,1],所以,最后一步是,將上述計算出來的y'值除以1/aspect,得出的結果才是齊次裁剪空間中真正的y值:
轉換之前的坐標記為(x, y, z, 1),轉換之后的坐標就是(x', y', z', 1),將之間計算的結果代入得到轉換之后的坐標是
在齊次坐標系統(tǒng)中,點(x, y, z, 1)和點(cx, cy, cz, c)表示的是同一個點,如果這個c不是0的話。利用這個性質,我們可以對上述坐標乘以一個z得到如下的形式
列出轉換矩陣表示這個轉換過程
轉換矩陣中有四個參數沒有確定,我們需要把這四個參數解出來。先來思考一下,對z進行投影,這和x,y坐標有沒有關系?顯然沒關系,所以m1和m2的值肯定是0。接下來考慮m3和m4,我們可以列出下面的方程
兩邊除以z得到
接著,我們說過齊次裁剪空間的z坐標范圍有兩種,一個是[0,1],一個是[-1,1],我們先計算[0,1]。這種轉換,近裁剪面(n)轉換到0,遠裁剪面(f)轉換到1,于是我們將坐標代入(注意我們所說的裁剪面n和f是距離)得到方程組
解方程組得到的結果是
將結果代入到轉換矩陣里,我們就能得到最終的轉換矩陣:
投影矩陣有很多變種,嗯,多到頭暈的變種。比如,之前我們?yōu)榱朔奖阌嬎悖瑢D換后的坐標值乘以了一個z得到的
顯然,我可以不乘z,如果我乘以一個-z得到的結果會是什么呢?
用這個結果去推導投影矩陣,得到的結果是:
很明顯,P2 = -P1。這兩個矩陣效果都一樣嗎?事實是,不一樣!P1是一個無效的透視矩陣,P2才是有效的矩陣。這里涉及到裁剪的相關原理。
透視矩陣會把所有的頂點都轉換到齊次裁剪空間中,為啥叫齊次裁剪空間?因為在這個空間中,會發(fā)生裁剪的操作。為啥是齊次?因為這個空間中的點,w坐標不是1,而是z或者-z。裁剪空間要做的第一件事,就是把w坐標小于0的頂點都剔除。而我們的z坐標,是一個小于0的值!所以,如果你用P1作為透視矩陣,那么你無法看到任何東西,因為它們都被剔除掉了。不信?試試~
推導這么復雜,寫成代碼就很簡單了,只要把結果賦值就好了。
float cFov = 1.f / tanf(xfov / 2);
result.m[0][0] = cFov;
result.m[1][1] = aspect * cFov;
result.m[2][3] = -1;
result.m[2][2] = far / (near - far);
result.m[3][2] = near * far / (near - far);
代碼的矩陣是P2矩陣的轉置,因為這種數據格式傳遞給渲染管線的時候計算方便。運行測試場景,得到的結果是:
另一個齊次裁剪空間
之前我們首先的齊次裁剪空間的z范圍是[0,1],那么如果它的范圍是[-1,1],又會是什么結果呢?下面我們來嘗試推導[-1,1]的投影矩陣,這個不難,只有最后轉換z坐標的時候有區(qū)別,我們以乘以-z為基礎來推導。
首先,m1和m2還是0,因為x坐標和y坐標對z投影之后的坐標值沒有影響。然后,列出z坐標轉換公式:
兩邊除以-z
現在,我們要把近裁剪面轉換成-1,遠裁剪面轉換成1,列出方程:
求得
最后的轉換矩陣是:
再用代碼實現:
float cFov = 1.f / tanf(xfov / 2);
result.m[0][0] = cFov;
result.m[1][1] = aspect * cFov;
result.m[2][3] = -1;
result.m[2][2] = (near + far) / (near - far);
result.m[3][2] = (2 * near * far) / (near - far);
應用這個投影矩陣,得到的結果是:
沒有半毛錢區(qū)別!
上面兩張圖是OpenGL下運行的結果,下面兩張圖是D3D12下運行的結果:
可以看到,上面兩張圖也沒啥區(qū)別。而對比OpenGL和D3D12的圖會發(fā)現,OpenGL中顯示的物體稍微偏上了一點,為什么會產生這種效果,筆者也不清楚。
如果不用fov,還能用什么參數計算投影矩陣?
在《3D游戲與計算機圖形學中的數學方法》一書和《Real Time Rendering 4th Edition》一書中,提供了一種不用fov的方法。這個方法是這樣的:
首先確定投影平面的位置,令投影平面正好位于近裁剪面處,它與觀察點之間的距離為n,所以其z坐標就是-n。然后,這個投影平面的x范圍是[l, r],y范圍是[b, t]。先通過相似原理列出一個點(x, y, z, 1)投影到上述投影平面上之后的x,y坐標,令投影后的坐標為(x', y', z', 1):
同理
現在,我們來計算假如投影平面上有一點(x', y'),要將其坐標映射到[-1,1]區(qū)間,計算公式是什么樣的?
因為投影平面上x坐標取值范圍是[l, r],我們可以通過x'到l的距離占(r - l)的比例來映射:
要將這個范圍轉換到[-1,1],只需要將這個范圍翻倍,然后左移一個單位的距離就可以,即:
同理
將x'和y'的式子代入到x''和y''的公式中
轉換后的點為
老規(guī)矩,乘以-z
列出矩陣
m3和m4的計算和上面一樣,我們直接寫出結果就行。對[0,1]區(qū)間是
對[-1,1]區(qū)間是
接著我們用代碼來實現這個投影矩陣:
// 注意矩陣要設置成我們推導結果的轉置。
void BuildPerspectiveFovRHMatrix(Matrix4f& result, const float l, const float r, float b, float t, const float n, const float f)
{
result.Set(0);
result.m[0][0] = 2 * n / (r - l);
result.m[1][1] = 2 * n / (t - b);
result.m[2][0] = (r + l) / (r - l);
result.m[2][1] = (t + b) / (t - b);
if (g_DepthClipSpace == DepthClipSpace::kDepthClipZeroToOne)
{
result.m[2][2] = f / (n - f);
result.m[3][2] = n * f / (n - f);
}
else /* g_DepthClipSpace == DepthClipSpace::kDepthClipNegativeOneToOne */
{
result.m[2][2] = (n + f) / (n - f);
result.m[3][2] = (2 * n * f) / (n - f);
}
return;
}
(fov)式投影矩陣和(l,r,b,t)式投影矩陣的聯(lián)系
這兩個的關系非常明確,把我們推導fov矩陣時假設的參數代入就行了。l等于-1,r等于1,b等于-1/aspect,t等于1/aspect。代入之后,(l,r,b,t)式矩陣就變成
而這里的n是投影平面到原點的距離,也就是說
于是(l,r,b,t)式矩陣就和(fov)式矩陣畫上等號了。如果n = 0.1,那么這個(l,r,b,t)式矩陣的xfov值就是168.58度。來看看運行效果:
OpenGL的顯示還是比D3D12偏上一些,整個物體也變小很多,因為我們這次的xfov值太大了。
到這里,我們就可以回答本節(jié)最開始的問題了,對DX和OpenGL渲染管線來說,不存在裁剪空間[0,1]還是[-1,1]的區(qū)別,而之所以印象中有這區(qū)別,是因為在寫DX和OpenGL代碼時,所用的數學庫一個是[0,1],一個是[-1,1]。
如果相機空間是左手坐標系,那怎么辦?
從最初的推理開始,左手坐標系改變了什么?投影平面的z坐標改變了,變成了隨之而來的x和y坐標的表達式也變了
緊接著,我們不用再乘以-z,列出的矩陣也變了
求解m3和m4的方程也變了
n和f坐標地改變,導致代入方程的數據也變了,先計算z范圍是[0,1]的轉換矩陣
求得的結果是
最終轉換矩陣為
計算z范圍是[-1,1]轉換矩陣的方程是
解的m3和m4的值為
最終的投影矩陣是
編寫代碼實現這個投影矩陣
// 還是要注意我們要按照轉置的思路去設置
void BuildPerspectiveFovLHMatrix(Matrix4f& result, const float xfov, const float aspect, const float near, const float far)
{
result.Set(0);
float cFov = 1.f / tanf(xfov / 2.f);
result.m[0][0] = cFov;
result.m[1][1] = aspect * cFov;
result.m[2][3] = 1.0f;
if (g_DepthClipSpace == DepthClipSpace::kDepthClipZeroToOne)
{
result.m[2][2] = -far / (near - far);
result.m[3][2] = near * far / (near - far);
}
else
{
result.m[2][2] = -(far + near) / (near - far);
result.m[3][2] = (2 * near * far) / (near - far);
}
return;
}
修改相應的代碼查看效果
不用看盒子的顯示狀況,只需看顯示的位置就可以了。因為這個場景是從blender導出的,而blender是右手坐標系的,所以它給出的頂點法線都是以右手坐標系給出的,我在這里做了一些小手腳,把攝像機放在了原點,在它背面放了一個盒子。用了一個左手坐標系的投影矩陣后,后面的盒子就顯示出來了。可以看到,位置和右手坐標系及對應的OpenGL和D3D12圖片是完全一致的,說明我們的推導沒有問題。
那么,不用xfov的投影矩陣怎么辦呢?
嗯,懶了,留給讀者推導吧。
補充說明
本文用于測試的程序是我在github上的Panda項目。項目的使用方法是:
(1)首先你得有一個環(huán)境,Win10+VS2017+CMake+Git+Git-Lfs+github賬號
(2)克隆項目到本地(克隆過程請耐心等待,原本github就不快,而且我項目里還有大文件(git-lfs更加慢得令人發(fā)指),100M以上的那種),逐個運行build_assimp.bat,build_crossguid.bat,build_libpng.bat,build_opengex.bat,build_zlib.bat
(3)運行build.bat
(4)打開build文件夾下,Panda.sln解決方案。
(5)運行EditorD3D或是EditorOGL。
投影矩陣的生成函數在Numerical.cpp文件中,使用的地方在GraphicsManager.cpp的CalculateCameraMatrix()函數中。模型加載的地方是EditorLogic.cpp的Initialize()函數。上面用到的模型文件是article.dae和article_lefthand.dae,模型文件在根目錄/Asset/Scene文件夾中,你可以隨意嘗試加載文件看效果。如果有什么困難,請在下方留言。
參考資料
《3D游戲與計算機圖形學中的數學方法》
《Real-Time Render 4th Edition》
《3D Graphics for Game Programming》