一、齊次坐標(biāo)

在3D世界中表示一個(gè)點(diǎn)的方式是:(x, y, z);然而在3D世界中表示一個(gè)向量的方式也是:(x, y, z);如果我們只給一個(gè)三元組(x, y, z)鬼知道這是向量還是點(diǎn),畢竟點(diǎn)與向量還是有很大區(qū)別的,點(diǎn)只表示位置,向量沒有位置只有大小和方向。為了區(qū)分點(diǎn)和向量我們給它加上一維,用(x, y, z, w)這種四元組的方式來表達(dá)坐標(biāo),我們規(guī)定(x, y, z, 0)表示一個(gè)向量,(x, y, z, 1)或(x', y', z', 2)等w不為0時(shí)來表示點(diǎn)。這種用n+1維坐標(biāo)表示n維坐標(biāo)的方式稱為齊次坐標(biāo)。

齊次坐標(biāo)除了能夠區(qū)分點(diǎn)和向量,在3D圖形學(xué)中還有重要的意義。齊次坐標(biāo)系使得我們可以在一中特殊的方程組中求出解,這個(gè)方程組中每一個(gè)方程都表示一個(gè)與系統(tǒng)中其他直線平行的直線。我們知道在歐幾里得空間中,對(duì)這種方程組是無解的,因?yàn)樗麄儧]有交點(diǎn)。然而在現(xiàn)實(shí)世界中我們是可以看到兩條平行線相交的。

兩條平行的鐵路最終相較于無窮遠(yuǎn)處。這就說明人眼看到的世界并不是歐幾里得空間,而是在一個(gè)名為透視空間中的世界。所以要在2D屏幕上表示3D世界,我們需要一個(gè)數(shù)學(xué)工具來承擔(dān)這項(xiàng)任務(wù),而齊次坐標(biāo)很完美的承擔(dān)了這項(xiàng)任務(wù)。

如果我們知道一個(gè)三維點(diǎn)的齊次坐標(biāo)為(X, Y, Z, w),那么它的3D空間坐標(biāo)為:

x = X / w

y = Y / w

z = Z / w

我們可以看到齊次坐標(biāo)(1, 2, 3, 1)與(2, 4, 6, 2)表示的都是3d空間中的點(diǎn)(1, 2, 3);所以通常在程序設(shè)計(jì)中我們都取w為1.

現(xiàn)在我們?cè)賮砜匆幌律厦嬲f的齊次坐標(biāo)在一組平行線中求解,有兩條直線:

Ax + By + Cz + D = 0

Ax + By + Cz + d = 0;

D不等于d;根據(jù)解析幾何知識(shí)我們可以知道這是兩條在歐幾里得空間中這是兩條相交的平行線,它們不可能有交點(diǎn)。如果d = D兩條直線會(huì)重合?,F(xiàn)在我們把他們用齊次坐標(biāo)來表示:

A(X/w) + B(Y/w) + C(Z/w) + D = 0;

A (X / w) + B (Y/w) + C (Z/w) + d = 0;

方程組兩邊同時(shí)乘以w得到:

AX + BY + CZ + Dw = 0;

AX + BY + CZ + dw = 0;

所以在齊次空間中對(duì)于四元組(X, Y, Z, w)(想一下極限的概念)當(dāng)w無限趨近于0時(shí),歐幾里得空間中的兩條平行線有無窮多個(gè)解(X, Y, Z, 0);他們?cè)贌o窮遠(yuǎn)處相交了。如同我們?nèi)搜劭吹降默F(xiàn)實(shí)世界中兩條平行線相交一樣。

二、矩陣迷宮

我們先來看一下在2d中將一個(gè)點(diǎn)(x, y)繞原點(diǎn)旋轉(zhuǎn) α 角度得到(x', y')的過程:

對(duì)于點(diǎn)x,y的極坐標(biāo)表示為:

x = r * cosβ

y = r * sinβ

旋轉(zhuǎn)后的坐標(biāo)x', y'為:

x' = r * cos(α + β) = rcosβcosα- rsinαsinβ = xcosα - ysinα

y' = r * sin(α + β) = rcosαsinβ + rsinαcosβ = ycosα + xsinα

那么我們用n+1為齊次坐標(biāo)并結(jié)合矩陣表示為:

我在學(xué)習(xí)webgl過程中經(jīng)常有一個(gè)疑問,為什么矩陣可以表示空間變換,有一個(gè)大牛告訴我,表示空間變換的并不是矩陣本身,而是一系列數(shù)學(xué)公式,就像上面用到的三角函數(shù)公式一樣,而矩陣的運(yùn)算法則能夠指把公式的運(yùn)算結(jié)果很好的表達(dá)出來。要想搞明白這些矩陣表示的空間變換需要自己手動(dòng)的把這些變換結(jié)果推導(dǎo)出來。

另外有看過opengl相關(guān)矩陣運(yùn)算的同學(xué)一定會(huì)發(fā)現(xiàn)上文中的運(yùn)算用的是行向量的形式,而opengl用的是列向量形式,從行向量到列向量只需要轉(zhuǎn)支一下即可。這里也正是我想重點(diǎn)強(qiáng)調(diào)的,對(duì)于初學(xué)者來說,行向量/列向量、行存儲(chǔ)/列存儲(chǔ)、以及平移旋轉(zhuǎn)的表達(dá)順序,這三者糅雜在一起很容易把人繞暈,因?yàn)樗?*2*2=8中情況。尤其是不同的書籍他們使用的表達(dá)方式、存儲(chǔ)方式、以及對(duì)運(yùn)算順序的表達(dá)是完全相反的(比如《3D數(shù)學(xué)基礎(chǔ)》跟《opengl權(quán)威指南》就是完全相反的),為了統(tǒng)一起見,我建議大家按照這樣的方式來思考問題:

1)webgl中使用的是列向量,對(duì)應(yīng)的縮放、平移、旋轉(zhuǎn)矩陣為:

2)webgl使用的是列存儲(chǔ)

在實(shí)際編程語言中,我們使用的一維數(shù)組來存儲(chǔ)4x4矩陣的16個(gè)元素。所謂的行存儲(chǔ)和列存儲(chǔ)的區(qū)分就在于數(shù)組的前四個(gè)元素存儲(chǔ)的是矩陣的第一列還是第一行;表示列的稱為列存儲(chǔ),表示行的成為行存儲(chǔ)。如下圖數(shù)組的前四個(gè)元素對(duì)應(yīng)矩陣中的第一列,所以是列存儲(chǔ)。

3)webgl中矩陣的運(yùn)算順序是從右往左進(jìn)行的

當(dāng)矩陣相乘時(shí),在最右邊的矩陣是第一個(gè)與向量相乘的,所以你應(yīng)該從右向左讀這個(gè)乘法。所以先旋轉(zhuǎn)后平移的的矩陣操作是TRV而不是RTV。我發(fā)現(xiàn)在跟同事討論問題時(shí),往往就是大家在這個(gè)地方有分歧而說到兩個(gè)不同的方向最后吵起來。因?yàn)橛械耐驴吹絋RV他按照左往右讀的說法是先平移后旋轉(zhuǎn),然后大家就在先平移后旋轉(zhuǎn)還是先旋轉(zhuǎn)后平移里爭執(zhí)。(跟人討論矩陣運(yùn)算順序時(shí)一定要寫在紙上)

矩陣乘法是不遵守交換律的,這意味著它們的順序很重要。建議您在組合矩陣時(shí),先進(jìn)行縮放操作,然后是旋轉(zhuǎn),最后才是位移,否則它們會(huì)(消極地)互相影響。比如,如果你先位移再縮放,位移的向量也會(huì)同樣被縮放(比如向某方向移動(dòng)2米,2米也許會(huì)被縮放成1米)!

確定矩陣運(yùn)算順序后,接下來要確定矩陣操作類庫的api的調(diào)用順序。向glmatrix這種類庫提供的api,對(duì)先旋轉(zhuǎn)后平移這種矩陣操作的實(shí)現(xiàn)方式是:(看api的很容易讓人認(rèn)為是先平移后旋轉(zhuǎn))

所以在webgl中一般api的調(diào)用順序都是跟矩陣的運(yùn)算順序相反的,這點(diǎn)與opengl一致。

另外注意一下:有很多書籍會(huì)告訴你一個(gè)矩陣的某些元素代表旋轉(zhuǎn),某幾個(gè)元素代表平移,比如左上角9個(gè)元素代表旋轉(zhuǎn),12-15代表平移,實(shí)際這些都不一定,一旦矩陣有了組合操作,那么這些都可能改變。

三、模型矩陣與模型視圖矩陣

現(xiàn)實(shí)世界中我們可以建立各種坐標(biāo)系,如果我們以一個(gè)物體原點(diǎn)(自己任意指定)來建立坐標(biāo)系,并且這個(gè)坐標(biāo)系在初始時(shí)與世界坐標(biāo)系重合,那么這個(gè)物體上的所有點(diǎn)的坐標(biāo)都是相對(duì)這個(gè)局部坐標(biāo)系來。如果我們移動(dòng)或者旋轉(zhuǎn)縮放物體,我們會(huì)使用一個(gè)矩陣來編碼這些變換。這個(gè)矩陣稱為模型矩陣。在我們用模型矩陣乘以我們對(duì)象中的頂點(diǎn)就得到一系列新的坐標(biāo),這些坐標(biāo)就是物體在世界坐標(biāo)系中的頂點(diǎn)位置。

我們?cè)?D屏幕上顯示三維物體,就像用相機(jī)拍攝圖像一樣。在三維世界中有一個(gè)假想的相機(jī),我們?cè)谄聊簧峡吹降膱鼍岸际窃谙鄼C(jī)坐標(biāo)系下表示的,要把世界坐標(biāo)系中的點(diǎn)轉(zhuǎn)化成相機(jī)坐標(biāo)系的點(diǎn),我們就需要一個(gè)變換矩陣。這個(gè)矩陣稱為模型視圖矩陣,而模型視圖矩陣就是相機(jī)的模型矩陣的逆矩陣。

我們想要看到世界中的任何場景只要控制相機(jī)的移動(dòng)和旋轉(zhuǎn)即可。用戶控制相機(jī)的過程主要是兩個(gè)事情:朝向和位置。只要這兩個(gè)屬性確定了,相機(jī)的模型矩陣以及模型視圖矩陣都可以得到了。用3D變換的角度來說就是旋轉(zhuǎn)和平移。可以想象對(duì)于任意一個(gè)3d場景我們都可以將相機(jī)做一個(gè)旋轉(zhuǎn)然后平移到一個(gè)位置來觀察到它的任意細(xì)節(jié)。

現(xiàn)在我們先改變相機(jī)的朝向然后平移到一個(gè)位置,這個(gè)模型矩陣為:

C = TR

T代表平移變換,R代表旋轉(zhuǎn)變換(R的前三列代表相機(jī)旋轉(zhuǎn)后的三個(gè)坐標(biāo)軸),那么這時(shí)候的模型視圖矩陣為:

這個(gè)C^-1就是我們要的模型視圖矩陣,上面說到相機(jī)旋轉(zhuǎn)后的三個(gè)軸是互相垂直的,也就是正交的,而正交矩陣的逆矩陣等于矩陣的轉(zhuǎn)置矩陣。所以C^-1最終變?yōu)椋?/p>

而T的逆矩陣很簡單:

最終的模型視圖矩陣為:

而我們?cè)谌S開發(fā)中常用的求模型視圖矩陣的方法lookAt用的就是這個(gè)原理。

這個(gè)函數(shù)主要需要三個(gè)參數(shù):eye代表相機(jī)位置、target代表相機(jī)的目標(biāo)點(diǎn)、up代表相機(jī)的上方向。我們稱相機(jī)模型矩陣的第一列代表相機(jī)的x軸,我們稱為right向量;第二列代表相機(jī)的y軸,我們稱為up向量,第三列代表相機(jī)的z軸,我們稱為相機(jī)軸(相機(jī)軸并不是相機(jī)的朝向,而是相機(jī)朝向的負(fù)方向,另外這里我們的相機(jī)的模型矩陣統(tǒng)一使用的右手系,有的資料里面用的是左手系)。

mat4.lookAt = function (eye, center, up, dest) {        if (!dest) { dest = mat4.create(); }        var x0, x1, x2, y0, y1, y2, z0, z1, z2, len,

            eyex = eye[0],

            eyey = eye[1],

            eyez = eye[2],

            upx = up[0],

            upy = up[1],

            upz = up[2],

            centerx = center[0],

            centery = center[1],

            centerz = center[2];        if (eyex === centerx && eyey === centery && eyez === centerz) {            return mat4.identity(dest);

        }        //vec3.direction(eye, center, z);

  // 首先根據(jù)觀察點(diǎn)和相機(jī)位置求得相機(jī)軸向量

        z0 = eyex - centerx;

        z1 = eyey - centery;

        z2 = eyez - centerz;        // normalize (no check needed for 0 because of early return)

  // 對(duì)相機(jī)軸做標(biāo)準(zhǔn)化

        len = 1 / Math.sqrt(z0 \* z0 + z1 \* z1 + z2 \* z2);

        z0 \*= len;

        z1 \*= len;

        z2 \*= len;        //vec3.normalize(vec3.cross(up, z, x));

  // up向量叉乘z軸得到x軸,即我們說的right向量

        x0 = upy \* z2 - upz \* z1;

        x1 = upz \* z0 - upx \* z2;

        x2 = upx \* z1 - upy \* z0;        len = Math.sqrt(x0 \* x0 + x1 \* x1 + x2 \* x2);        if (!len) {

            x0 = 0;

            x1 = 0;

            x2 = 0;

        } else {            len = 1 / len;

            x0 \*= len;

            x1 \*= len;

            x2 \*= len;

        }        //vec3.normalize(vec3.cross(z, x, y));

  // 然后根據(jù)z軸叉乘x軸得到相機(jī)的y軸

        y0 = z1 \* x2 - z2 \* x1;

        y1 = z2 \* x0 - z0 \* x2;

        y2 = z0 \* x1 - z1 \* x0;        len = Math.sqrt(y0 \* y0 + y1 \* y1 + y2 \* y2);        if (!len) {

            y0 = 0;

            y1 = 0;

            y2 = 0;

        } else {            len = 1 / len;

            y0 \*= len;

            y1 \*= len;

            y2 \*= len;

        }  // 最終得到的模型視圖矩陣為:R^T \* T^-1

        dest[0] = x0;

        dest[1] = y0;

        dest[2] = z0;

        dest[3] = 0;

        dest[4] = x1;

        dest[5] = y1;

        dest[6] = z1;

        dest[7] = 0;

        dest[8] = x2;

        dest[9] = y2;

        dest[10] = z2;

        dest[11] = 0;

        dest[12] = -(x0 \* eyex + x1 \* eyey + x2 \* eyez); // -x軸點(diǎn)乘eye向量

        dest[13] = -(y0 \* eyex + y1 \* eyey + y2 \* eyez); // -y軸點(diǎn)乘eye向量

        dest[14] = -(z0 \* eyex + z1 \* eyey + z2 \* eyez); // -z軸點(diǎn)乘eye向量

        dest[15] = 1;        return dest;

    };

這里講的都是通過先改變相機(jī)朝向然后改變相機(jī)位置的方式來觀察三維場景中的物體,實(shí)際上也通過別的方式比如先將相機(jī)平移到一個(gè)位置,然后繞世界坐標(biāo)系旋轉(zhuǎn)的方式來觀察場景,這種算法的效果就像是將相機(jī)固定在軌道上一樣(我們通過先改變朝向在平移也能達(dá)到這種效果),在有的資料中它把這種先平移后旋轉(zhuǎn)方式稱為軌道相機(jī),把先旋轉(zhuǎn)后平移稱為跟蹤相機(jī)。

四、透視矩陣

通過模型視圖變換,3d場景中的物體已經(jīng)能夠用相機(jī)空間坐標(biāo)來表達(dá),接下來我們處理的是如何來模擬人眼的近大遠(yuǎn)小效果。相機(jī)坐標(biāo)系中的物體還是處于3d世界中,要做出近大遠(yuǎn)小的效果還需要繼續(xù)變換。這個(gè)變換被稱為透視投影,它的特點(diǎn)是所有投影線都從空間一點(diǎn)投射,離視點(diǎn)近的物體投影大,離視點(diǎn)小的物體投影小,小到極點(diǎn)稱為滅點(diǎn)。

一般將屏幕放在觀察者和物體之間。投影線與屏幕的焦點(diǎn)就是物體點(diǎn)上的透視投影。這里我們的觀察點(diǎn)就是相機(jī)的位置。

大家對(duì)透視投影有了基本認(rèn)識(shí),現(xiàn)在我們來說一些透視除法也叫視錐體裁切,什么意思呢?大家想一下我們?nèi)搜凼遣皇侵荒芸吹揭徊糠值氖澜鐑?nèi)容,而不是全部,我們視野范圍之外的內(nèi)容已經(jīng)被過濾掉了,所以在3d圖形學(xué)模擬人眼的過程中也有一步就是將多余內(nèi)容裁切掉。

在3d圖形學(xué)中我們模擬透視投影是通過一個(gè)六面體構(gòu)造出投影矩陣來做透視效果:

除了穿過投影面正中心的投影線沒有變形外,與其它投影線相交的點(diǎn)都存在變形。假設(shè)點(diǎn)p在相機(jī)坐標(biāo)系下為(x, y, z),對(duì)應(yīng)著投影面上的點(diǎn)為p'(x', y', z').

在相機(jī)坐標(biāo)系下,一個(gè)點(diǎn)在投影線上的投影對(duì)應(yīng)為它的z分量,那么根據(jù)三角形相似法則,我們就能求出對(duì)應(yīng)的x'和y'與z的關(guān)系:

n/|z| = y' / y

y' = n*y / |z|

因?yàn)檫@里在相機(jī)坐標(biāo)系下,z是負(fù)數(shù)所以|z| = -z,那么上式變?yōu)椋簓' = n * y / (-z);

同理可求出x為:x' = n * x / (-z);

那么我們得到的投影再近平面的坐標(biāo)p'為(nx / (-z), ny / (-z), -n);

這個(gè)時(shí)候我們發(fā)現(xiàn)p'的z分量永遠(yuǎn)都是-n,也就是說原來p的z在投影后已經(jīng)丟失了。

讓我們先放一下,接下來我們說一下透視除法,透視除法的目的是把投影變換后xyz任意一個(gè)不在-w與w之間的物體去除掉。然而在視錐體這里面做裁切并不容易,所以數(shù)學(xué)前輩想了一個(gè)方式,讓我們用一個(gè)立方體來做裁切,webgl中經(jīng)過透視投影變換后的物體經(jīng)過透視除法后會(huì)在一個(gè)xyz都是-1到1的立方體之間,這時(shí)候的坐標(biāo)稱為設(shè)備歸一化坐標(biāo),簡稱ndc。

那么既然投影到近平面那部分坐標(biāo)的z值已經(jīng)丟了,反正后面也要變換到ndc,干脆這里直接用一種方式來表示歸一化后的z;這里我們這樣設(shè)置近平面坐標(biāo)p':

p' = (-nx/z, -ny/z, (az+b) / z);

可以看到x', y' 都與1/z成線性關(guān)系,所以這里讓z'也與1/z保持線性關(guān)系,所以:Z(ndc) = b / z + a;

那么將p'變?yōu)辇R次坐標(biāo)后為:

(nx, ny, -az - b, -z);

這時(shí)候齊次坐標(biāo)來源于,投影矩陣x視坐標(biāo) = p';

m * [x, y, z, 1]T = [nx, ny, -az - b, -z]T

可的矩陣m為:

前面已經(jīng)說到用(az + b)/z直接對(duì)應(yīng)ndc坐標(biāo),所以

當(dāng)z = -n時(shí),(az + b)/z = -1;

當(dāng)z = -f時(shí), (az + b)/z = 1;

聯(lián)立方程組得:

a = (n+f)/(f-n)

b = (2nf)/(f-n)

現(xiàn)在的矩陣m為:

現(xiàn)在Zndc已經(jīng)滿足了-1到1的結(jié)果;我們的x', y' 還停留在投影平面坐標(biāo)中,還需要由投影面轉(zhuǎn)到ndc坐標(biāo)中,數(shù)學(xué)家在處理二者的關(guān)系時(shí),選擇了如下的對(duì)應(yīng)關(guān)系:

(-nx/z - left)/(right - left) = X(ndc) - (-1) / (1 - (-1))得到

X(ndc) = -(2nx/z)/ (right - left) - (right+left)/(right-left);

(-ny/z - bottom) / (top - bottom) = Y(ndc) - (-1) / (1 - (-1))得到

Y(ndc) = - (2ny/z) / (top - bottom) - (top + bottom) / (top - bottom);

那么現(xiàn)在P(ndc) = [-(2nx/z)/ (right - left) - (right+left)/(right-left), - (2ny/z) / (top - bottom) - (top + bottom) / (top - bottom), (az+b)/z, 1]

齊次坐標(biāo)為:

[2nx/(right-left) + (right+left)z/(right - left), 2ny/(top - bottom)+(top+bottom)z/(top-bottom), -az-b, -z];

而齊次坐標(biāo)由矩陣運(yùn)算:

m ' * [x, y, z, 1]T = [2nx/(right-left) + (right+left)z/(right - left), 2ny/(top - bottom)+(top+bottom)z/(top-bottom), -az-b, -z]T

可以得到矩陣m'為:

那么現(xiàn)在m'就是我們最終直接變換到ndc坐標(biāo)系下的變換矩陣。

這里可以看到這個(gè)變換矩陣完全由視錐體的六個(gè)參數(shù)構(gòu)成。我們?cè)谵D(zhuǎn)換到ndc的過程中,對(duì)于x和y首先中間轉(zhuǎn)換到投影平面坐標(biāo),由于投影后的z丟失,所以使用一個(gè)跟原來1/z成線性關(guān)系的表達(dá)式對(duì)應(yīng)到Z(ndc)得到a跟b的值,然后由投影屏幕坐標(biāo)與ndc的一個(gè)對(duì)應(yīng)關(guān)系得到最終變換到ndc坐標(biāo)系下X(ndc),Y(ndc)與視坐標(biāo)系中x,y的對(duì)應(yīng)關(guān)系,最終得到終極的透視矩陣。

而通常的類庫api都會(huì)提供設(shè)置視錐體的方法,比如gl-matrix:

fovy對(duì)應(yīng)的角度稱為俯仰角:

根據(jù)關(guān)系三角函數(shù)可以算出top;

aspect為寬高比:width/height用來根據(jù)top計(jì)算出left和right;

五、屏幕坐標(biāo)變換

與之前的步驟不同,視口變換不是由矩陣變換產(chǎn)生的。在這里我們使用webgl的viewport函數(shù)。變換函數(shù)為:

function fromSreenToNdc(x, y, container) {
return {
x: x / container.offsetWidth * 2 - 1,
y: -y / container.offsetHeight * 2 + 1,
z: 1
};
}
function fromNdcToScreen(x, y, container) {
return {
x: (x + 1) / 2 * container.offsetWidth,
y: (1 - y) / 2 * container.offsetHeight
};
}

這里可以看到aspect最好設(shè)置為canvas.offsetWidth/canvas.offsetHeight,通過前面的圖可以知道投影面可能是矩形面,而ndc是正方形,所以投影過程中會(huì)產(chǎn)生變形,而ndc到屏幕坐標(biāo)中也是會(huì)產(chǎn)生變形,也就是我們讓投影面到ndc有變形,然后讓ndc到屏幕在變形回去,這樣就能保證最終顯示在屏幕上的3d物體保持原來比例。

參考資料:

詳解MVP矩陣之齊次坐標(biāo)和ModelMatrix

[

變換

](https://learnopengl-cn.github.io/01%20Getting%20started/07%20Transformations/)

詳解MVP矩陣之ViewMatrix

深入探索透視投影變換

OpenGL中的坐標(biāo)變換、矩陣變換

http://www.cnblogs.com/dojo-lzz/p/7223364.html