UnityShader精要筆記二 數(shù)學(xué)基礎(chǔ) MVP實(shí)例

在讀第五章第三節(jié)Unity內(nèi)置文件和變量之前,建議細(xì)讀第四章。嗯,重修過(guò)之前的線代和圖形學(xué)基礎(chǔ)后,這一章能看得稍微輕松一些。

一、知識(shí)點(diǎn)匯總
1.正交基和標(biāo)準(zhǔn)正交基

該知識(shí)點(diǎn)出現(xiàn)在4.2.2節(jié)。
3個(gè)坐標(biāo)軸互相垂,且長(zhǎng)度為1,這樣的基矢量被稱為標(biāo)準(zhǔn)正交基,但這并不是必須的。在一些坐標(biāo)系中坐標(biāo)軸之間互相垂直,但長(zhǎng)度不為1,這樣的基矢量被稱為正交基。正交,可以理解為互相垂直的意思。

2.正交矩陣

圖形學(xué)筆記二 正交矩陣、轉(zhuǎn)置矩陣和旋轉(zhuǎn)中已經(jīng)了解過(guò)正交矩陣的重要性質(zhì):正交矩陣是指其轉(zhuǎn)置等于逆的矩陣。在《入門(mén)精要》的4.4節(jié)有詳細(xì)介紹:

在三維變換中,我們經(jīng)常會(huì)使用逆矩陣來(lái)求解反向的變換。但逆矩陣的求解往往計(jì)算量很大,而如果我們可以確定這個(gè)矩陣是正交矩陣的話,就可以直接通過(guò)轉(zhuǎn)置矩陣得到逆矩陣。

那么如何判斷的一個(gè)矩陣是否是正交矩陣呢,當(dāng)然可以通過(guò)公式M右乘M的轉(zhuǎn)置矩陣是否為單位矩陣來(lái)判斷,但這仍然需要一定的計(jì)算量,這些計(jì)算量可能和直接求解逆矩陣無(wú)異。而且,如果我們判斷出來(lái)這不是一個(gè)正交矩陣,那么這些花在驗(yàn)證是否是正交矩陣的計(jì)算就浪費(fèi)了。因此,我們更想不需要計(jì)算,而僅僅根據(jù)一個(gè)矩陣的構(gòu)造過(guò)程來(lái)判斷這個(gè)矩陣是否是正交矩陣。為此,我們需要了解正交矩陣的幾何意義。


image.png

這樣,我們就有了9個(gè)等式:


image.png

可以得到如下結(jié)論:
  • 矩陣的每一行(即c1,c2,c3)都是單位矢量,因?yàn)樗鼈兣c自己的點(diǎn)積為1
  • 矩陣的每一行(即c1,c2,c3)都互相垂直,因?yàn)樗鼈兓ハ嗟狞c(diǎn)積為0(參考點(diǎn)積的公式|a||b|cosθ)
  • 上述的兩條結(jié)論對(duì)每一列也同樣適用。因?yàn)镸是正交矩陣的話,MT也是正交矩陣。

也就說(shuō)如果一個(gè)矩陣滿足上面的條件,那么它就是一個(gè)正交矩陣。讀者可以注意到, 一組標(biāo)準(zhǔn)正交基(定義詳見(jiàn)4.2.2 節(jié)〉可以精確地滿足上述條件。在4.6.2 節(jié)中,我們會(huì)使用坐標(biāo)空間的基矢量來(lái)構(gòu)建用于空間變換的矩陣。因此,如果這些基矢量是一組標(biāo)準(zhǔn)正交基的話(例如只存在旋轉(zhuǎn)變換),那么我們就可以直接使用轉(zhuǎn)置矩陣來(lái)求得該變換的逆變換。

讀者: 我被標(biāo)準(zhǔn)正交、正交這些概念搞混了,可以再說(shuō)明一下是什么意思嗎?

我們:讀者應(yīng)該已經(jīng)知道, 一個(gè)坐標(biāo)空間需要指定一組基矢量,也就是我們理解的坐標(biāo)軸。如果這些基矢量之間是互相垂直的,那么我們就把它們稱為是一組正交基( orthogonal basis ) .但是,它們的長(zhǎng)度并不要求一定是1 。如果它們的長(zhǎng)度的確是1 的話,我們就說(shuō)它們是一組標(biāo)準(zhǔn)正交基( orthonormal basis )。

因此,一個(gè)正交矩陣的行和列之間分別構(gòu)成了一組標(biāo)準(zhǔn)正交基。但是, 如果我們使用一組正交基來(lái)構(gòu)建一個(gè)矩陣的話,這個(gè)矩陣可能就不是一個(gè)正交矩陣,因?yàn)檫@些基矢量的長(zhǎng)度可能不為1 ,也就是說(shuō)它們不是標(biāo)準(zhǔn)正交基。

3.行矩陣還是列矩陣

假設(shè)有一個(gè)矢量v=(x,y,z),我們可以把它轉(zhuǎn)換成行矩陣(x,y,z)或列矩陣(x,y,z)T。在Unity 中,常規(guī)做法是把矢量放在矩陣的右側(cè), 即把矢量轉(zhuǎn)換成列矩陣來(lái)進(jìn)行運(yùn)算。因此,在本書(shū)后面的內(nèi)容中, 如無(wú)特殊情況, 我們都將使用列矩陣。這意味著, 我們的矩陣乘法通常都是右乘,例如:CBAv = (C(B(Av)))

使用列向量的結(jié)果是,我們的閱讀順序是從右到左,即先對(duì)v使用A進(jìn)行變換,再使用B進(jìn)行變換,最后使用C進(jìn)行變換。

4.平移矩陣不是一個(gè)正交矩陣
image.png
5.縮放矩陣一般不是正交矩陣

如果縮放系數(shù)kx=ky=kz,我們把這樣的縮放稱為統(tǒng)一縮放( uniform scale ), 否則稱為非統(tǒng)一 縮放( nonuniform scale )。

從外觀上看,統(tǒng)一縮放是擴(kuò)大整個(gè)模型,而非統(tǒng)一縮放會(huì)拉伸或擠壓模 型。更重要的是,統(tǒng)一縮放不會(huì)改變角度和比例信息,而非統(tǒng)一縮放會(huì)改變與模型相關(guān)的角度 和比例。例如在對(duì)法線進(jìn)行變換時(shí),如果存在非統(tǒng)一縮放,直接使用用于變換頂點(diǎn)的變換矩陣的話,就會(huì)得到錯(cuò)誤的結(jié)果。正確的變換方法可參見(jiàn)4 .7 節(jié)。

縮放矩陣的逆矩陣是使用原縮放系數(shù)的倒數(shù)來(lái)對(duì)點(diǎn)或方向矢量進(jìn)行縮放,即


image.png

縮放矩陣一般不是正交矩陣。

上面的矩陣只適用于沿坐標(biāo)軸方向進(jìn)行縮放。如果我們希望在任意方向上進(jìn)行縮放,就需要使用一個(gè)復(fù)合變換。其中一種方法的主要思想就是,先將縮放軸變換成標(biāo)準(zhǔn)坐標(biāo)軸,然后進(jìn)行沿坐標(biāo)軸的縮放,再使用逆變換得到原來(lái)的縮放軸朝向。

6.旋轉(zhuǎn)矩陣是正交矩陣

旋轉(zhuǎn)矩陣的逆矩陣是旋轉(zhuǎn)相反角度得到的變換矩陣。旋轉(zhuǎn)矩陣是正交矩陣,而且多個(gè)旋轉(zhuǎn)矩陣之間的串聯(lián)同樣是正交的。

7.復(fù)合變換順序是先縮放,再旋轉(zhuǎn),最后平移

在絕大多數(shù)情況下,我們約定變換的順序就是先縮放,再旋轉(zhuǎn),最后平移。

讀者:為什么要約定這樣的順序,而不是其他順序呢?

我們:因?yàn)檫@樣的變換順序是我們需要的。想象我們對(duì)奶牛妞妞進(jìn)行一個(gè)復(fù)合變換。如果我們按先平移、再縮放的順序進(jìn)行變換,假設(shè)初始情況下妞妞位于原點(diǎn),我們先按(0, 0, 5)平移它,現(xiàn)在它距離原點(diǎn)5 個(gè)單位。然后再將它放大2 倍,這樣所有的坐標(biāo)都變成了原來(lái)的2倍,而這意味著妞妞現(xiàn)在的位置是(0, 0, 10),這不是我們希望的。正確的做法是,先縮放再平移。也就是說(shuō),我們先在原點(diǎn)對(duì)妞妞進(jìn)行2 倍的縮放,再進(jìn)行平移,這樣妞妞的大小正確了,位置也正確了。

8.旋轉(zhuǎn)順序

如果我們需要同時(shí)繞著3 個(gè)軸進(jìn)行旋轉(zhuǎn),是先繞x 軸、再繞y 軸最后繞z 軸旋轉(zhuǎn)還是按其他的旋轉(zhuǎn)順序呢?
當(dāng)我們直接給出( θx, θy, θz)這樣的旋轉(zhuǎn)角度時(shí),需要定義一個(gè)旋轉(zhuǎn)順序。在Unity 中,這個(gè)旋轉(zhuǎn)順序是zxy,這在旋轉(zhuǎn)相關(guān)的API 文檔中都有說(shuō)明。這意味著,當(dāng)給定(θx, θy, θz)這樣的旋轉(zhuǎn)角度時(shí),得到的組合旋轉(zhuǎn)變換矩陣是:


image.png

一些讀者會(huì)有疑問(wèn):上面的公式書(shū)寫(xiě)順序是不是反了?不是說(shuō)列矩陣要從右往左讀嗎?這樣 一來(lái)順序不就顛倒了嗎?實(shí)際上,有一個(gè)非常重要的東西我們沒(méi)有說(shuō)明白,那就是旋轉(zhuǎn)時(shí)使用的 坐標(biāo)系。給定一個(gè)旋轉(zhuǎn)順序(例如這里的zxy ),以及它們對(duì)應(yīng)的旋轉(zhuǎn)角度(θx, θy, θz),有兩種坐標(biāo) 系可以選擇。

  • 繞坐標(biāo)系E下的z 軸旋轉(zhuǎn)θz,繞坐標(biāo)系E 下的y 軸旋轉(zhuǎn)θy,繞坐標(biāo)系E 下的x 軸旋轉(zhuǎn)θx,即進(jìn)行一次旋轉(zhuǎn)時(shí)不一起旋轉(zhuǎn)當(dāng)前坐標(biāo)系。
  • 繞坐標(biāo)系E下的z 軸旋轉(zhuǎn)θz,在坐標(biāo)系E 下在繞z 軸旋轉(zhuǎn)θz 后的新坐標(biāo)系E’下的y 軸旋轉(zhuǎn)θy , 在坐標(biāo)系E’下再繞y 軸旋轉(zhuǎn)θy 后的新坐標(biāo)系E”下的x 軸旋轉(zhuǎn)θx,即在旋轉(zhuǎn)時(shí),把坐標(biāo)系一起轉(zhuǎn)動(dòng)。

很容易知道,這兩種選擇的結(jié)果是不一樣的。但如果把它們的旋轉(zhuǎn)順序顛倒一下,它們得到的結(jié)果就會(huì)是一樣的!說(shuō)得明白點(diǎn),在第一種情況下,按zxy 順序旋轉(zhuǎn)和在第二種情況下,按yxz順序旋轉(zhuǎn)是一樣的。而Unity 文檔中說(shuō)明的旋轉(zhuǎn)順序指的是在第一種情況下的順序。

和上面不同類型的變換順序?qū)е碌膯?wèn)題類似,不同的旋轉(zhuǎn)順序得到的結(jié)果也可能是不一樣的。我們同樣可以通過(guò)對(duì)比不同旋轉(zhuǎn)順序得到的變換矩陣來(lái)理解為什么會(huì)出現(xiàn)這樣的不同。而這個(gè)驗(yàn)證過(guò)程留給讀者作為練習(xí)。

這里也可以參考圖形學(xué)筆記三 復(fù)數(shù) 四元數(shù)

9.坐標(biāo)空間的變換

作者在4.6節(jié)舉了一個(gè)例子,其實(shí)用基變換的思路更快,例子需求如下:

現(xiàn)在,我們已知坐標(biāo)空間C的三個(gè)坐標(biāo)軸在父坐標(biāo)空間P下的表示Xc,Yc,Zc,以及原來(lái)的原點(diǎn)Oc。當(dāng)給定一個(gè)子坐標(biāo)空間中的一點(diǎn)Ac=(a,b,c),我們同樣可以依照上面4個(gè)步驟來(lái)確定其在父坐標(biāo)空間下的位置Ap

應(yīng)用基變換,就是直接把子空間的Xc,Yc,Zc直接往父空間進(jìn)行變換,直接就到這里了:


image.png

然后轉(zhuǎn)化為齊次坐標(biāo):


image.png

一旦求出來(lái)Mc->p,Mp->c就可以通過(guò)求逆矩陣的方式求出來(lái),因?yàn)閺淖鴺?biāo)空間C變換到坐標(biāo)空間P與從坐標(biāo)空間P變換到坐標(biāo)空間C是互逆的兩個(gè)過(guò)程。

可以看出來(lái),變換矩陣Mc->p實(shí)際上可以通過(guò)坐標(biāo)空間C在坐標(biāo)空間P中的原點(diǎn)和坐標(biāo)軸的矢量表示構(gòu)建出來(lái):把3個(gè)坐標(biāo)軸依次放入矩陣的前三列,把原點(diǎn)矢量放到最后一列,再用0和1填充最后一行即可。

需要注意的是,這里我們并沒(méi)有要求3個(gè)坐標(biāo)軸Xc、Yc和Zc是單位矢量,事實(shí)上,如果存在縮放的話,這三個(gè)矢量值很可能不是單位矢量。

更加令人振奮的是,我們可以利用反向思維,從這個(gè)變換矩陣反推來(lái)獲取子坐標(biāo)空間的原點(diǎn)和坐標(biāo)軸方向。例如,當(dāng)我們已知從模型空間到世界空間的一個(gè)4×4的變換矩陣,可以提取它的第一列再進(jìn)行歸一化后(為了消除縮放的影響)來(lái)得到模型空間的x軸在世界空間下的單位矢量表示。同樣的方法也可以提取y軸和z軸。我們可以從另一個(gè)角度來(lái)理解這個(gè)提取過(guò)程。因?yàn)榫仃嘙c->p可以把一個(gè)方向矢量從坐標(biāo)空間C變換到坐標(biāo)空間P中,那么,我們只需要用它來(lái)變換坐標(biāo)空間C中的x軸(1,0,0,0),即使用矩陣乘法M->p[1 0 0 0]T,得到的結(jié)果正是Mc->p的第一列。

另一個(gè)有趣的情況是,對(duì)方向矢量的坐標(biāo)空間變換。我們知道,矢量是沒(méi)有位置的,因此坐標(biāo)空間的原點(diǎn)變換是可以忽略的。也就是說(shuō),我們僅僅平移坐標(biāo)系的原點(diǎn)是不會(huì)對(duì)矢量造成任何影響的。那么,對(duì)矢量的坐標(biāo)空間變換就可以使用3×3矩陣來(lái)表示,因?yàn)槲覀儾恍枰揭谱儞Q。那么變換矩陣就是:


image.png

在shader中,我們常常會(huì)看到截取變換矩陣的前3行前3列來(lái)對(duì)法線方向、光照方向來(lái)進(jìn)行空間變換,這正是原因所在。

現(xiàn)在,我們?cè)賮?lái)關(guān)注Mp->c。我們前面講到,可以通過(guò)求Mc->p的逆矩陣方式求解出來(lái)反向變換Mp->c。但有一種情況我們不需要求解逆矩陣就可以得到Mp->c,這種情況就是Mc->p是一個(gè)正交矩陣。

如果它是一個(gè)正交矩陣的話,Mc->p的逆矩陣就等于它的轉(zhuǎn)置矩陣。這意味著我們不需要進(jìn)行復(fù)雜的求逆操作就可以得到反向變換。也就是說(shuō),如果我們知道坐標(biāo)空間變換矩陣Ma->b是一個(gè)正交矩陣,那么我們可以提取它的第一列來(lái)得到坐標(biāo)空間A的x軸在坐標(biāo)空間B下的表示,還可以提取它的第一行來(lái)得到坐標(biāo)空間B的x軸在坐標(biāo)空間A下的表示。反過(guò)來(lái),如果我們知道坐標(biāo)空間B的x軸、y軸和z軸(必須是單位矢量,否則構(gòu)建出來(lái)的就不是單位矩陣了)在坐標(biāo)空間A下的表示,就可以把它們依次放在矩陣的每一行就可以得到A到B的變換矩陣了。

6.讓人發(fā)暈的例子

當(dāng)你不知道把坐標(biāo)軸的表示是按行放還是按列放的時(shí)候,不妨先選擇一種擺放方式來(lái)得到變換矩陣。例如,現(xiàn)在我們想把一個(gè)矢量從坐標(biāo)空間A 變換到坐標(biāo)空間B,而且我們已經(jīng)知道坐標(biāo)空間B 的x 軸、y 軸、z 軸在空間A 下的表示,即X B 、Y B 和Z B 。那么想要得到從A 到B 的變換矩陣M A→B ,我們是把它們按列放呢還是按行放呢?如果讀者實(shí)在想不起來(lái)正確答案,我們不妨先隨便選擇一種方式,例如按列擺放。那么


image.png

這個(gè)才是正確的

這里馮樂(lè)樂(lè)的解釋,我是真的看暈了。但是我仍然可以用基變換的思路來(lái)解釋:
我想得到A到B的變換矩陣,只需要把A的基坐標(biāo)變到B就行了,但問(wèn)題就是,已知條件是反的,是知道B的基坐標(biāo)在A中的表示。所以我改改思路,先求B到A的變換矩陣,再求逆即可。

B到A的基坐標(biāo)變換,根據(jù)上面說(shuō)的,在Unity 中,常規(guī)做法是把矢量放在矩陣的右側(cè), 即把矢量轉(zhuǎn)換成列矩陣來(lái)進(jìn)行運(yùn)算。也就是:


image.png

然后對(duì)這個(gè)矩陣變換求逆,因?yàn)槭钦痪仃嚕苯愚D(zhuǎn)置即可得到最終答案:


image.png
二、MVP實(shí)例

圖形學(xué)筆記四 MVP中,已經(jīng)了解了MVP基本流程。原書(shū)舉了一個(gè)例子,講得很細(xì),就是有個(gè)奶牛叫妞妞,她有自己的坐標(biāo)空間即模型空間,在這個(gè)空間里,她的鼻子坐標(biāo)是(0,2,4),最后如何顯示在屏幕上呢?

1.模型變換(model transform)

首先,轉(zhuǎn)化為齊次坐標(biāo)(0,2,4,1)。

頂點(diǎn)變換的第一步就是將頂點(diǎn)坐標(biāo)從模型空間變換到世界空間,這個(gè)變換通常叫做模型變換(model transform)。根據(jù)Transform的信息,妞妞進(jìn)行了(2,2,2)的縮放,(0,150,0)的旋轉(zhuǎn)以及(5,0,25)的平移。根據(jù)之前的知識(shí),要先縮放再旋轉(zhuǎn)再平移:

image.png

2.觀察變換(view transform)

觀察空間(view space)也被稱為攝像機(jī)空間(camera space)。觀察空間可以認(rèn)為是模型空間的一個(gè)特例——在所有的模型中有一個(gè)非常特殊的模型,即攝像機(jī)(雖然通常來(lái)說(shuō)攝像機(jī)本身是不可見(jiàn)的),它的模型空間值得我們單獨(dú)拿出來(lái)討論,也就是觀察空間。

攝像機(jī)決定了我們渲染游戲所使用的視角。在觀察空間中,攝像機(jī)位于原點(diǎn),同樣其坐標(biāo)軸的選擇可以是任意的,但由于我們是以u(píng)nity為主,而unity中觀察空間的坐標(biāo)軸選擇是:+x指向右方,+y指向上方,而正z軸指向攝像機(jī)的后方。在這里,讀者可能會(huì)覺(jué)得奇怪,我們之前討論的模型空間和世界空間中+z軸指的都是物體的前方,為什么這里不一樣了呢?這是因?yàn)閁nity在模型空間和世界空間選用的都是左手坐標(biāo)系,而觀察空間中使用的是右手坐標(biāo)系。這是復(fù)合OpenGL的傳統(tǒng)的,在這樣的觀察空間中,攝像機(jī)的正前方指向的是-z軸方向。

這種左右手坐標(biāo)系之間改變很少會(huì)對(duì)我們?cè)趗nity中的編程產(chǎn)生影響,因?yàn)閡nity為我們做了許多渲染底層的工作。但是如果讀者需要調(diào)用類似Camera.cameraToWorldMatrix、Camera.WorldToCameraMatrix等接口自行計(jì)算某模型在觀察空間中的位置上,就要小心這樣的差異。

最后提醒讀者的一點(diǎn)是,觀察空間和屏幕空間是不同的。觀察空間是一個(gè)三維空間,而屏幕空間是一個(gè)二維空間。從觀察空間到屏幕空間需要一個(gè)操作,那就是投影(projection)。我們后面會(huì)講到。

頂點(diǎn)變換的第二步,就是將頂點(diǎn)坐標(biāo)從世界空間變換到觀察空間中。這個(gè)變換叫觀察變換(view transform)

圖形學(xué)筆記四 MVP中有提到:

上面的截圖說(shuō)的是,考慮到運(yùn)動(dòng)的相對(duì)性,如果相機(jī)和物體一起移動(dòng),那么拍出來(lái)的照片是相同的。沿著這種思路,把相機(jī)放在世界坐標(biāo)的原點(diǎn),并讓坐標(biāo)軸與世界空間重合,然后再讓物體移動(dòng),就能達(dá)到同樣的效果。

這里也介紹一下正常的思路,根據(jù)基變換的思路。要得到世界坐標(biāo)的物體在相機(jī)空間的坐標(biāo),可以把世界坐標(biāo)的基轉(zhuǎn)換到相機(jī)空間。以Unity舉例,我們更容易獲得的是攝像機(jī)在世界空間中的坐標(biāo),所以需要對(duì)這個(gè)變換進(jìn)行求逆,才能得到我們的目標(biāo)矩陣。

這兩種思路,最終選擇的是把相機(jī)移到原點(diǎn)的思路。視頻中說(shuō)到這樣做的好處:會(huì)讓操作得到簡(jiǎn)化

這里說(shuō)一下自己的理解,以我的眼睛舉例,當(dāng)我向前方進(jìn)行移動(dòng)時(shí),會(huì)發(fā)現(xiàn)所有物體都在向后方移動(dòng)。那么在游戲中,以世界坐標(biāo)系為參考,一個(gè)攝像機(jī)的坐標(biāo)發(fā)生變化時(shí),就模擬了我的眼睛在移動(dòng),此時(shí)在我的眼睛坐標(biāo)系內(nèi),所有物體的頂點(diǎn)坐標(biāo)全部反向移動(dòng)了。也就是,要考慮攝像機(jī)和物體的相對(duì)關(guān)系,以攝像機(jī)為參考系,把物體的頂點(diǎn)坐標(biāo)轉(zhuǎn)換到攝像機(jī)坐標(biāo)系內(nèi)。這個(gè)移動(dòng)的矩陣如何計(jì)算出來(lái)呢?正常的思路就是,把世界坐標(biāo)系的基轉(zhuǎn)換到相機(jī)坐標(biāo)系即可,但是這樣計(jì)算卻比較繁瑣,因?yàn)橐阎獥l件里,更容易得到的是攝像機(jī)在世界空間中的坐標(biāo)和其它屬性。其實(shí)這里就有個(gè)簡(jiǎn)化的轉(zhuǎn)換方式,就是把相機(jī)的坐標(biāo)和其它屬性,還原到世界坐標(biāo)系與其重合,然后把所有物體的頂點(diǎn)也這么操作一遍,顯然它們的相對(duì)關(guān)系是不變的。而這個(gè)還原過(guò)程,正是我們要求出的移動(dòng)矩陣,還原計(jì)算非常簡(jiǎn)單,之前攝像機(jī)怎么變換的,現(xiàn)在逆回去就行了。

回到我們的農(nóng)場(chǎng)游戲?,F(xiàn)在我們需要把妞妞的鼻子從世界空間變換到觀察空間中。為此我們需要知道世界坐標(biāo)系下攝像機(jī)的變換信息。這同樣可以通過(guò)攝像機(jī)面板中的Transform組件得到:(1,1,1)的縮放,(30,0,0)的旋轉(zhuǎn),(0,10,-10)的平移。

為了把攝像機(jī)重新移回到初始狀態(tài)(這里指攝像機(jī)原點(diǎn)位于世界坐標(biāo)原點(diǎn)、坐標(biāo)軸與世界空間中的坐標(biāo)軸重合),我們需要進(jìn)行逆向變換,即先按(0,-10,10)進(jìn)行平移,以便攝像機(jī)回到原點(diǎn),再按(-30,0,0)進(jìn)行旋轉(zhuǎn),以便讓坐標(biāo)軸重合。因此變換矩陣就是:

image.png

注意,這里繞X軸旋轉(zhuǎn)的公式很容易寫(xiě)出,而如果繞任意軸,這個(gè)矩陣會(huì)復(fù)雜很多。具體參考圖形學(xué)筆記二 正交矩陣、轉(zhuǎn)置矩陣和旋轉(zhuǎn)

但是,由于觀察空間使用的是右手坐標(biāo)系,因此需要對(duì)z分量進(jìn)行取反操作。我們可以通過(guò)乘以另一個(gè)特殊矩陣來(lái)得到最終的觀察變換矩陣:


image.png
3.裁剪空間

頂點(diǎn)接下來(lái)要從觀察空間轉(zhuǎn)換到裁剪空間(clip space,也被稱為齊次裁剪空間)中,這個(gè)用于變換的矩陣叫做裁剪矩陣(clip matrix),也被稱為投影矩陣(projection matrix)。

裁剪矩陣的目標(biāo)是能夠方便的對(duì)渲染圖元進(jìn)行裁剪:完全位于這塊空間內(nèi)部的圖元會(huì)被保留,完全位于這塊空間外部的圖元將會(huì)被剔除,而與這塊空間相交的圖元將會(huì)被裁剪。那么這塊空間是如何決定的呢?答案是由視椎體(view frustum)來(lái)決定。

視椎體是指空間中的一片區(qū)域,這塊區(qū)域決定了攝像機(jī)可以看到的空間。視椎體由6個(gè)平面包圍而成,這些平面也被稱為裁剪平面(clip planes)。視椎體有兩種類型,這涉及到兩種投影類型:一種是正交投影(orthographic projection),一種是透視投影(perspective projection)。

在視椎體的6塊裁剪平面中,有兩塊裁剪平面比較特殊,它們分別被稱為近裁剪平面(near clip plane)和遠(yuǎn)裁剪平面(far clip plane)。它們決定了攝像機(jī)可以看到的深度的范圍。正交投影和透視投影的視椎體如下圖所示。


image.png

從上圖可以看出,透視投影的視椎體是一個(gè)金字塔形,側(cè)面的4個(gè)裁剪平面會(huì)在攝像機(jī)處相交。它更符合視椎體這個(gè)詞語(yǔ)。正交投影的視椎體是一個(gè)長(zhǎng)方體。前面講到,我們希望根據(jù)視椎體圍成的區(qū)域?qū)D元進(jìn)行裁剪,但是如果直接使用視椎體定義的空間來(lái)進(jìn)行裁剪,那么不同的視椎體就需要不同的處理過(guò)程,而且對(duì)于透視投影的視椎體來(lái)說(shuō),想要判斷一個(gè)頂點(diǎn)是否處于一個(gè)金字塔內(nèi)部是比較麻煩的。因此我們想要一種更加通用、方便和整潔的方式來(lái)進(jìn)行裁剪的工作,這種方式就是通過(guò)一個(gè)投影矩陣把頂點(diǎn)轉(zhuǎn)換到一個(gè)裁剪空間中。

投影矩陣有兩個(gè)目的:

(1)首先為投影做準(zhǔn)備。這是個(gè)迷惑點(diǎn),雖然投影矩陣的名稱包含了投影2字,但它并沒(méi)有進(jìn)行真正的投影工作,而是在為投影做準(zhǔn)備。真正的投影發(fā)生在后面的齊次除法(homogeneous division)過(guò)程中。而經(jīng)歷過(guò)投影矩陣的變換后,頂點(diǎn)w的分量會(huì)具有特殊的意義。

讀者:投影到底是什么意思呢?
我們:可以理解成是一個(gè)空間的降維,例如從四維空間投影到三維空間中。而投影矩陣實(shí)際上并不會(huì)真的進(jìn)行這個(gè)步驟,它會(huì)為真正的投影做準(zhǔn)備工作。真正的投影會(huì)在屏幕映射時(shí)發(fā)生,通過(guò)齊次除法來(lái)得到二維坐標(biāo)。

(2)其次是對(duì)x、y、z分量進(jìn)行縮放。我們上面講過(guò)直接使用視椎體的6個(gè)裁剪平面進(jìn)行裁剪會(huì)比較麻煩。而經(jīng)過(guò)投影矩陣的縮放后,我們可以直接使用w分量作為一個(gè)范圍值,如果x、y、z分量都位于這個(gè)范圍內(nèi),就說(shuō)明該頂點(diǎn)位于裁剪空間內(nèi)。

在裁剪空間之前,雖然我們使用了齊次坐標(biāo)來(lái)表示點(diǎn)和矢量,但它們的第四個(gè)分量都是固定的:點(diǎn)的w分量是1,方向矢量的w分量是0。經(jīng)過(guò)投影矩陣變換后,我們會(huì)賦予齊次坐標(biāo)的第四個(gè)坐標(biāo)更加豐富的含義。下面,我們來(lái)看一下兩種投影類型使用的投影矩陣具體是什么。

4.透視投影

視椎體的意義在于定義了場(chǎng)景中的一塊三維空間。所有位于這塊空間內(nèi)的物體都會(huì)被渲染,否則就會(huì)被剔除或裁減。我們已經(jīng)知道這塊區(qū)域由6個(gè)裁剪平面定義,那么這6個(gè)裁剪平面又是怎么決定的呢?在Unity中,它們由Camera組件中的參數(shù)和Game視圖的橫縱比共同決定,如圖所示。


image.png

由圖可以看出,我們可以通過(guò)Camera組件的Field of View(簡(jiǎn)稱FOV)屬性來(lái)改變視椎體豎直方向的張開(kāi)角度,而Clipping Planes中的Near和Far參數(shù)可以控制視椎體的近裁剪平面和遠(yuǎn)裁剪平面距離攝像機(jī)的遠(yuǎn)近。這樣我們可以求出視椎體近裁剪平面和遠(yuǎn)裁剪平面的高度,也就是:


image.png

現(xiàn)在我們還缺乏橫向信息。這可以通過(guò)攝像機(jī)的橫縱比得到。在Unity中,一個(gè)攝像機(jī)的橫縱比由Game視圖的橫縱比和Viewport Rect中的W和H屬性共同決定(實(shí)際上,Unity允許我們?cè)谀_本里通過(guò)Camera.aspect進(jìn)行更改,但這里不做討論)。假設(shè),當(dāng)前攝像機(jī)的橫縱比為Aspect,我們定義:
image.png

現(xiàn)在,我們可以根據(jù)已知的Near、Far、FOV和Aspect的值來(lái)決定透視投影的投影矩陣。如下:


image.png

上面公式的推導(dǎo)部分可以參見(jiàn)本章的擴(kuò)展閱讀部分。需要注意的是,這里的投影矩陣是建立在Unity對(duì)坐標(biāo)系的假定上面,也就是說(shuō),我們針對(duì)的是觀察空間為右手坐標(biāo)系,使用列矩陣在矩陣右側(cè)進(jìn)行相乘,且變換z分量范圍將在[-w,w]之間的情況。而在類似DirectX這樣的圖形接口中,它們希望變換后z分量范圍將在[0,w]之間,因此就需要對(duì)上面的透視矩陣進(jìn)行更改。這不在本書(shū)的討論范圍內(nèi)。
而一個(gè)頂點(diǎn)和上述矩陣相乘后,可以由觀察空間變換到裁剪空間中,結(jié)果如下:
image.png

從結(jié)果可以看出,這個(gè)投影矩陣本質(zhì)就是對(duì)x、y和z分量進(jìn)行了不同程度的縮放(當(dāng)然,z分量還做了一個(gè)平移),縮放的目的是為了方便裁剪。我們可以注意到,此時(shí)頂點(diǎn)的w分量不再是1,而是原先z分量的取反結(jié)果?,F(xiàn)在,我們就可以按如下不等式來(lái)判斷一個(gè)變換后的頂點(diǎn)會(huì)否位于視錐體內(nèi),如果一個(gè)頂點(diǎn)在視錐體內(nèi),那么它變換后的坐標(biāo)必須滿足:
image.png

任何不滿足上述條件的圖元都需要被剔除或裁減。下圖顯示了經(jīng)過(guò)上述投影矩陣后,視椎體的變化:
image.png

從上圖還可以注意到,裁剪矩陣會(huì)改變空間的旋向性:空間從右手坐標(biāo)系變換到了左手坐標(biāo)系,這意味著離攝像機(jī)越遠(yuǎn),z值將越大。

注:這一部分我看得有點(diǎn)崩,說(shuō)一下個(gè)人理解,為了模擬人眼遠(yuǎn)小近大的透視效果,在把3D物體投影到一個(gè)平面上時(shí),需要把生成的平面圖進(jìn)行壓縮變形,這樣最終看起來(lái)才會(huì)有立體效果。壓縮變形這個(gè)操作,正是我們要推導(dǎo)的矩陣。Games101的課程中的方法,分三步:擠壓、正交投影、組合矩陣。在圖形學(xué)筆記四 MVP中,閆令琪是先講了正交投影,然后基于正交投影又用了不少時(shí)間推出了透視投影。

5.正交投影

這個(gè)在圖形學(xué)筆記四 MVP講得是很清楚的,簡(jiǎn)單很多,先平移,再縮放:

image.png

在Unity中,引入了Aspect參數(shù),比起閆令琪講的稍有變化,但原理是共通的。
image.png

由圖可以看出,我們可以通過(guò)Camera組件的Size屬性來(lái)改變視椎體豎直方向高度的一半,而Clipping Planes中的Near和Far參數(shù)可以控制視椎體的近裁剪平面和遠(yuǎn)裁剪平面距離攝像機(jī)的遠(yuǎn)近。這樣,我們可以求出視椎體近裁剪平面和遠(yuǎn)裁剪平面的高度,也就是:
image.png

現(xiàn)在我們還缺乏橫向的信息。同樣我們可以通過(guò)攝像機(jī)的縱橫比得到。假設(shè),當(dāng)前攝像機(jī)的縱橫比為Aspect,那么:
image.png

現(xiàn)在,我們可以根據(jù)已知的Near、Far、Size和Aspect的值來(lái)確定正交投影的裁剪矩陣。如下:
image.png

同樣,這里的投影矩陣是建立在Unity對(duì)坐標(biāo)系的假定上面的。一個(gè)頂點(diǎn)和上述投影矩陣相乘后的結(jié)果如下:
image.png

注意到,和透視投影不同的是,使用正交投影的投影矩陣對(duì)頂點(diǎn)進(jìn)行變換后,其w分量仍然為1。本質(zhì)是因?yàn)橥队熬仃嚨淖詈笠恍胁煌?,透視投影的投影矩陣的最后一行是[0,0,-1,0],而正交投影的投影矩陣的最后一行是[0,0,0,1]。這樣的選擇是有原因的,是為了齊次除法做準(zhǔn)備,在后面我們會(huì)講到。

判斷一個(gè)變換后的頂點(diǎn)是否位于視椎體內(nèi)使用的等式和透視投影中一樣,這種通用性也是為什么要使用投影矩陣的原因之一。下圖顯示了經(jīng)過(guò)上述投影矩陣后,正交投影的視椎體變化。

image.png

同樣,裁剪矩陣改變了空間的旋向性??梢宰⒁獾?,經(jīng)過(guò)正交投影變換后的頂點(diǎn)實(shí)際已經(jīng)位于一個(gè)立方體內(nèi)了。

6.繼續(xù)來(lái)看我們的農(nóng)場(chǎng)游戲

在上面,我們已經(jīng)幫妞妞確定了它的鼻子在觀察空間中的位置——(9,8。84,-27.31)?,F(xiàn)在,我們要計(jì)算它在裁剪空間中的位置。

首先,我們需要知道農(nóng)場(chǎng)游戲中使用的攝像機(jī)類型。由于農(nóng)場(chǎng)游戲是一個(gè)3D游戲,因此這里我們使用了透視攝像機(jī)。攝像機(jī)參數(shù)和Game視圖的縱橫比如圖所示:


image.png

據(jù)此,我們可以知道透視投影的參數(shù):FOV為60度,Near為5,F(xiàn)ar為40,Aspect為4/3=1.33。那么對(duì)應(yīng)的投影矩陣是:


image.png

然后,我們用這個(gè)投影矩陣來(lái)把妞妞的鼻子從觀察空間轉(zhuǎn)換到裁剪空間中。如下
image.png

接下來(lái)Unity會(huì)判斷妞妞的鼻子是否需要裁剪。通過(guò)比較得到,妞妞的鼻子滿足下面的不等式:


image.png

由此,我們可以判斷,妞妞的鼻子位于視椎體內(nèi),不需要被裁減。
7.屏幕空間

經(jīng)過(guò)投影矩陣的變換后,我們可以進(jìn)行裁剪操作。當(dāng)完成了所有的裁剪操作后,就需要進(jìn)行真正的投影了,也就是說(shuō)我們需要把視椎體投影到屏幕空間(screen space)中。經(jīng)過(guò)這一步變換,我們會(huì)得到真正的像素位置,而不是虛擬的三維坐標(biāo)。

屏幕空間是一個(gè)二維空間,因此我們必須把頂點(diǎn)從裁剪空間投影到屏幕空間中,來(lái)生成對(duì)應(yīng)的2D坐標(biāo)。這個(gè)過(guò)程可以理解成有兩個(gè)步驟。

首先,我們需要進(jìn)行齊次除法(homogeneous division),也被稱為透視除法(perspective division)。雖然這個(gè)步驟聽(tīng)起來(lái)很陌生,但實(shí)際上它非常簡(jiǎn)單,就是用齊次坐標(biāo)的w分量去除以x,y,z分量。在OpenGL中,我們把這一步得到的坐標(biāo)叫做歸一化的設(shè)備坐標(biāo)(Normalized Device Coordinates,NDC)。經(jīng)過(guò)這一步,我們可以把坐標(biāo)從齊次裁剪坐標(biāo)空間轉(zhuǎn)換到NDC中。經(jīng)過(guò)透視投影變換后的裁剪空間,經(jīng)過(guò)齊次除法后會(huì)變到一個(gè)立方體內(nèi)。按照OpenGl傳統(tǒng),這個(gè)立方體的x,y,z分量的范圍都是[-1,1]。但是在DirectX這樣的API中,z的分量范圍會(huì)是[0,1]。而Unity選擇了OpenGL這樣的裁剪空間,如下圖所示:


image.png

而對(duì)于正交投影來(lái)說(shuō),它的裁剪空間實(shí)際上已經(jīng)是一個(gè)立方體了,而且由于經(jīng)過(guò)正交投影矩陣變換后的頂點(diǎn)的w分量是1,因此齊次除法并不會(huì)對(duì)頂點(diǎn)的x,y,z坐標(biāo)產(chǎn)生影響,如下圖所示:


image.png

經(jīng)過(guò)齊次除法后,透視投影和正交投影的視椎體都變換到相同的立方體內(nèi),現(xiàn)在我們可以根據(jù)變換后的x和y坐標(biāo)來(lái)映射輸出窗口的對(duì)應(yīng)像素坐標(biāo)。
在Unity中,屏幕空間左下角的像素坐標(biāo)是(0,0),右上角的像素坐標(biāo)是(pixelWidth,pixelHeight),由于當(dāng)前x和y坐標(biāo)都是[-1,1],因此,這個(gè)映射的過(guò)程就是一個(gè)縮放的過(guò)程。

齊次除法和屏幕映射的過(guò)程可以使用下面的公式來(lái)總結(jié):


image.png

上面的式子對(duì)x和y分量都進(jìn)行了處理,那么z分量呢?通常,z分量會(huì)被用于深度緩沖。一個(gè)傳統(tǒng)的方式是把clipz/clipx的值直接存進(jìn)深度緩存中,但這并不是必須的。通常驅(qū)動(dòng)產(chǎn)商會(huì)根據(jù)硬件來(lái)選擇最好的存儲(chǔ)格式。此時(shí)clipw也并不會(huì)被拋棄,雖然它完成了它的主要工作——在齊次除法中作為分母來(lái)得到NDC,但它仍然會(huì)在后續(xù)的一些工作中起到重要作用,例如進(jìn)行透校正插值。
在Unity中,從裁剪空間到屏幕空間的轉(zhuǎn)換是由底層幫我們完成的,我們的頂點(diǎn)著色器只需要把頂點(diǎn)轉(zhuǎn)換到裁剪空間即可。
在上一步中,我們知道了裁剪空間中妞妞鼻子的位置——(11.691,15.311,23.692,27.31)?,F(xiàn)在我們終于可以確定妞妞鼻子在屏幕上像素的位置。假設(shè),當(dāng)前屏幕的寬度為400,高度為300。首先我們要進(jìn)行齊次除法,把裁剪的坐標(biāo)投影到NDC中,然后再映射到屏幕空間中。這個(gè)過(guò)程如下:
image.png

由此,我們知道了妞妞鼻子在屏幕上的位置——(285.617,234.096)

注:關(guān)于NDC坐標(biāo)系至屏幕坐標(biāo)系的計(jì)算,可以參考
https://www.bilibili.com/video/BV1jC4y1k71o

image.png

在上面的例子中,可以理解為使用11.691/27.31得到NDC坐標(biāo)系中的X分量,然后再去計(jì)算11.691/27.31*(400/2)+400/2

8.總結(jié)

以上就是一個(gè)頂點(diǎn)如何從模型空間變換到屏幕坐標(biāo)的過(guò)程,下圖總結(jié)了這些空間和用于變換的矩陣:


image.png

頂點(diǎn)著色器最基本的任務(wù)就是把頂點(diǎn)坐標(biāo)從模型空間轉(zhuǎn)換到裁剪空間中。這對(duì)應(yīng)了圖中前三個(gè)頂點(diǎn)變換過(guò)程。而在片元著色器中,我們通常也可以得到該片元在屏幕空間的像素位置。我們會(huì)在以后的講解中看到如何得到這些像素的位置。
在Unity中,坐標(biāo)系的旋向性也隨著變換發(fā)生了改變。下圖總結(jié)了Unity各個(gè)空間使用的坐標(biāo)系旋向性。


image.png

從圖中可以發(fā)現(xiàn),只有在觀察空間中Unity使用了右手坐標(biāo)系。
需要注意的是,這里給出的僅僅是一些重要的坐標(biāo)空間。還有一些空間在實(shí)際開(kāi)發(fā)中也會(huì)遇到,例如切線空間(tangent space)。切線空間通常用于法線映射,在后面我們會(huì)說(shuō)到。

注:4.8節(jié)的內(nèi)置變量,放在第5章一起學(xué)習(xí)。

二、threejs中關(guān)于MVP的代碼

參考
three.js+WebGL踩坑經(jīng)驗(yàn)合集(4.1):THREE.Line2的射線檢測(cè)問(wèn)題(注意本篇說(shuō)的是Line2,同樣也不是閾值方面的問(wèn)題)
three.js+WebGL踩坑經(jīng)驗(yàn)合集(4.2):為什么不在可視范圍內(nèi)的3D點(diǎn)投影到2D的結(jié)果這么不可靠

1.THREE.Vector3 的project方法

THREE.Vector3.project 方法的主要作用是將一個(gè)三維向量投影到屏幕坐標(biāo)系中。具體來(lái)說(shuō),它會(huì)將三維空間中的點(diǎn)轉(zhuǎn)換為一個(gè)標(biāo)準(zhǔn)化的屏幕坐標(biāo),其范圍通常為 -1 到 1。

// 創(chuàng)建一個(gè)向量對(duì)象
const vector = new THREE.Vector3(0, 0, 1); // 示例點(diǎn)在Z軸上

// 假設(shè)我們有一個(gè)相機(jī)和場(chǎng)景
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const scene = new THREE.Scene();

// 設(shè)置相機(jī)位置
camera.position.z = 5;

// 創(chuàng)建一個(gè)光源
const light = new THREE.DirectionalLight(0xffffff);
light.position.set(1, 1, 1).normalize();
scene.add(light);

// 創(chuàng)建一個(gè)網(wǎng)格對(duì)象作為物體
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 渲染循環(huán)
function animate() {
    requestAnimationFrame(animate);
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 更新相機(jī)位置
    camera.position.z = 5 + Math.sin(cube.rotation.x) * 5;

    // 渲染場(chǎng)景
    renderer.render(scene, camera);

    // 獲取屏幕坐標(biāo)
    const screenPosition = new THREE.Vector3();
    screenPosition.setFromMatrixPosition(camera.matrixWorld);
    screenPosition.project(camera); // 使用相機(jī)進(jìn)行投影

    console.log('Screen Position:', screenPosition);
}
animate();

在這個(gè)示例中,我們通過(guò)調(diào)用 project 方法將相機(jī)的世界坐標(biāo)轉(zhuǎn)換為屏幕坐標(biāo),并輸出到控制臺(tái)。
打開(kāi)Vector3.js的源碼:

project(camera) {
    return this.applyMatrix4(camera.matrixWorldInverse).applyMatrix4(camera.projectionMatrix);
}

applyMatrix4(m) {
 
    const x = this.x, y = this.y, z = this.z;
    const e = m.elements;
 
    const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
 
    this.x = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w;
    this.y = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w;
    this.z = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w;
 
    return this;
 
}
image.png

將源碼與上文的計(jì)算示例對(duì)照,可以這樣理解(注意threejs的matrix數(shù)組是列主序):


image.png

易見(jiàn),源碼中的w變量,是為了方便計(jì)算,將除法中的分母寫(xiě)成1/上述圖片中的w

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容