本文主要解決一個問題:
如何創(chuàng)建一個FPS攝像機(jī)?
引言
在前一章中,我們討論了觀察矩陣以及如何使用變換矩陣移動場景(雖然僅僅是往后移了一點點)。本章中,我們要創(chuàng)建一個類似FPS的攝像機(jī),它可以移動,可以轉(zhuǎn)頭,可以變焦(狙擊槍里開放大鏡效果)。
在這章中,你會看到
- 觀察空間變換的內(nèi)部原理
- 鍵盤操縱攝像機(jī)前后左右移動的方法
- 鼠標(biāo)操縱攝像機(jī)上下左右轉(zhuǎn)動的方法
- 實現(xiàn)變焦的方式
- 將攝像機(jī)功能封裝成類(該死,好久沒這么有創(chuàng)造性的封裝一個類了,碼農(nóng)當(dāng)太久腦子都秀逗了。)
觀察(攝像機(jī))空間
就像前一章說的那樣,觀察空間其實是以攝像機(jī)為原點,以攝像機(jī)觀察的方向為-z軸方向的坐標(biāo)系統(tǒng)。而觀察矩陣的作用,就是將場景中的物體從世界坐標(biāo)轉(zhuǎn)換到觀察坐標(biāo)。要定義一個攝像機(jī)系統(tǒng),我們需要它在世界空間中的位置,它的朝向,以及一個向上方向的向量。

1、相機(jī)位置
相機(jī)位置就是一個簡單的向量,表示其在世界空間中的位置。我們把它設(shè)置成和前一章一樣的位置。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 4.0f);
別忘了OpenGL是右手坐標(biāo)系,攝像機(jī)是往-z軸方向看的
2、光線方向
作為朝向的反方向,我稱它為光線方向(物體反射光攝入觀察者眼睛的方向)。計算的方式很簡單,將相機(jī)位置向量和觀察目標(biāo)點向量做減法就可以了。我們使用世界坐標(biāo)原點(默認(rèn)點)作為我們的觀察目標(biāo)點。
glm::vec3 cameraTarget glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
3、Right軸
我們下一個需要的向量是Right向量,它表示坐標(biāo)系統(tǒng)中的x軸正方向。要計算這個Right向量,我們要用到之前學(xué)的一點小技巧:向量叉乘。Right向量必須要垂直于光線方向,因此,它必須要和光線方向與世界坐標(biāo)系統(tǒng)的y軸組成的平面垂直。這就幫了我們的大忙,根據(jù)叉乘規(guī)則,我們只需要將y軸的單位向量與光線方向向量做叉乘就可以了。
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
4、Up軸
現(xiàn)在,我們有了x軸和z軸,y軸已經(jīng)呼之欲出了。沒錯,只需要用z軸向量叉乘x軸向量就可以了!
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
叉乘真是個好東西!
好,坐標(biāo)系統(tǒng)的三個軸都有了,馬上開始生成觀察矩陣。
觀察矩陣
用矩陣的最大好處就是當(dāng)你有了坐標(biāo)空間的3個軸之后,再加上一個位置向量就可以創(chuàng)造一個變換矩陣。用這個矩陣乘上任何向量都可以將這個向量轉(zhuǎn)換到觀察坐標(biāo)系中。我們集齊了這些條件,可以召喚神龍了:

R表示Right向量,U表示Up向量,D表示光線方向,P表示位置向量。注意,位置向量取的是它的反方向,因為物體需要朝著攝像機(jī)相反的方向移動才行。
總結(jié)一下我們需要用到的數(shù)據(jù):攝像機(jī)的位置,攝像機(jī)的觀察目標(biāo)(可以生成光線方向),還有世界空間的Up向量。使用這些數(shù)據(jù),通過計算,我們就可以生成任意的觀察矩陣。非常幸運的是,glm已經(jīng)幫我們封裝好了一個函數(shù),調(diào)用它,我們可以直接獲取到觀察矩陣(而且不用擔(dān)心出錯?。?。
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 4.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
驗證一下函數(shù)的效果。我們把攝像機(jī)的位置放在半徑為10的圓上,讓它的觀察點始終在世界空間原點上,并且,攝像機(jī)會不斷地在圓上移動。
float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0f, camZ), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

是不是很贊?
移動相機(jī)
讓相機(jī)在場景中轉(zhuǎn)圈是挺有趣的,不過更有趣的還是我們自己來控制相機(jī)的移動。第一步,我們要來創(chuàng)建一個相機(jī)系統(tǒng),這需要我們在程序開始的時候定義一些關(guān)于相機(jī)的變量。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 4.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
觀察矩陣就會變成這個樣子:
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
我們希望攝像機(jī)的朝向不變而不是觀察目標(biāo)不變,所以觀察點就變成cameraPos+cameraFront?,F(xiàn)在,我們就要用鍵盤操作移動!
在我們之前定義的processInput函數(shù)的最后添加一些代碼
float cameraSpeed = 0.05f; //移動速度
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp) * cameraSpeed);
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp) * cameraSpeed);
這樣,我們可以使用WASD鍵來控制前后左右的移動了。
等等,是不是還露了點什么?對了,時間!這段代碼純粹是基于按鍵和代碼運行速度來控制的,如果機(jī)子不好,代碼運行慢點移動的速度也會變慢,這就不太科學(xué)了。因此,我們引入時間來計算移動的距離。
先定義兩個全局的變量,用來保存上一幀繪制的時間以及兩幀之間的間隔時間。
float deltaTime = 0.0f; //兩幀之間的間隔時間
float lastFrame = 0.0f; //上一幀繪制的時間
然后,每一幀都更新這兩個數(shù)值:
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
最后,在processInput中使用這個數(shù)值
float cameraSpeed = 2.5f * deltaTime; //移動速度
編譯運行。

在左右方向上移動地非???,筆者也試過調(diào)小2.5f這個數(shù)值,但是經(jīng)過嘗試,即便是將2.5調(diào)成0.01在左右方向上移動地還是很快,而前后方向上就太慢了。
環(huán)顧四周
只用WASD控制移動還不算一個完整的FPS攝像機(jī),我們還要能轉(zhuǎn)頭才行!
要實現(xiàn)轉(zhuǎn)頭的功能呢,我們就要對cameraFront向量進(jìn)行改變了。不過對方向向量的改變比較復(fù)雜,還涉及要一些三角學(xué)的知識。如果你不了解三角學(xué),跳過下面這一段也無妨,直接到代碼的地方,等你想了解原理的時候再回來。
歐拉角
歐拉角是繞著三條軸旋轉(zhuǎn)的一個值(歐拉這個名字應(yīng)該很熟悉吧)。一共有3中歐拉角,分別是:pitch、yaw和roll。(避免歧義,直接用英文。)

pitch表示我們平時抬頭低頭的動作,yaw表示左看右看,roll表示,嗯,二哈打滾就是這種效果,咱不適合。每個歐拉角組合起來之后,我們可以表示任何旋轉(zhuǎn)。
作為一個FPS攝像機(jī),我們只需要pitch和yaw兩種旋轉(zhuǎn)就行了。通過三角計算,將方向向量設(shè)置成新值。

上圖就是pitch旋轉(zhuǎn)的計算方法。我們的初始方向為(0, 0, -1)。當(dāng)我們想要轉(zhuǎn)動pitch角度時,z坐標(biāo)就等于-cos(pitch),y坐標(biāo)就等于sin(pitch),因為我們假定了斜邊長度為1,只考慮其方向。

類似的,計算yaw的方法也是如此,z坐標(biāo)等于-cos(yaw),x坐標(biāo)等于-sin(yaw)。
將兩個旋轉(zhuǎn)整合起來:
x = -sin(yaw)*cos(pitch)
y = sin(pitch)
z = -cos(pitch) * cos(yaw)
鼠標(biāo)輸入
pitch和yaw的值是通過鼠標(biāo)的移動得到的,水平方向上的移動代表了yaw的值,垂直方向上的移動代表了pitch的值。我們需要保存上一次鼠標(biāo)的位置,這樣可以通過計算和這次鼠標(biāo)位置的差值算出轉(zhuǎn)動的角度。不過首先,我們我們需要把鼠標(biāo)的光標(biāo)隱藏起來,并且捕獲鼠標(biāo)消息。
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
glfwSetCursorPosCallback(window, mouse_callback);
mouse_callback是響應(yīng)鼠標(biāo)消息的回調(diào)函數(shù),原型如下:
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
window表示捕獲的窗口,xpos表示x坐標(biāo),ypos表示y坐標(biāo)。
為了計算一個方向向量,我們需要做這么幾件事:
- 計算鼠標(biāo)相對于上一次的位置偏移。
- 將偏移值累加到攝像機(jī)的yaw和pitch值中去。
- 添加一些旋轉(zhuǎn)的限制
- 計算方向向量
先看代碼
if (firstMouse) { //設(shè)置初始位置,防止突然跳到某個方向上
lastX = xPos;
lastY = yPos;
firstMouse = false;
}
float xoffset = lastX - xPos; //別忘了,在窗口中,左邊的坐標(biāo)小于右邊的坐標(biāo),而我們需要一個正的角度
float yoffset = lastY - yPos; //同樣,在窗口中,下面的坐標(biāo)大于上面的坐標(biāo),而我們往上抬頭的時候需要一個正的角度
lastX = xPos;
lastY = yPos;
float sensitivity = 0.05f; //旋轉(zhuǎn)精度
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
if (pitch > 89.0f) //往上看不能超過90度
pitch = 89.0f;
if (pitch < -89.0f) //往下看也不能超過90度
pitch = -89.0f;
glm::vec3 front;
front.x = -sin(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = -cos(glm::radians(pitch)) * cos(glm::radians(yaw));
cameraFront = glm::normalize(front);
為了防止突然跳到某個方向,我們在鼠標(biāo)剛開始的時候?qū)λ奈恢眠M(jìn)行設(shè)置。
接下來,計算與上次位置的偏移量,然后乘上旋轉(zhuǎn)精度得到旋轉(zhuǎn)的角度值。
然后,將旋轉(zhuǎn)角度累加到pitch和yaw值中去,并且,設(shè)置pitch的最大和最小值。
最后,根據(jù)我們上面推倒的公式,計算方向向量,并將其規(guī)范化。
將這段代碼寫入到mouse_callback函數(shù)中,編譯運行!

這正是我們想要的!如果現(xiàn)實不對,可以下載源碼比較。這里只提供本章寫的源碼,資源以及其他代碼可以到上一篇文章中下載。
變焦
變焦功能,就是狙擊槍的放大鏡頭。通過改變視野值來達(dá)到效果,將fov值變小,我們就能看到遠(yuǎn)方更精細(xì)的畫面,將fov值變大,我們就可以看到更廣的畫面,當(dāng)然也失去了精度優(yōu)勢。
那么我們?nèi)绾潍@得fov的改變值呢?答案是通過鼠標(biāo)滾輪消息來模擬!
//鼠標(biāo)滾輪消息回調(diào)
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) {
if (fov >= 1.0 && fov <= 45.0)
fov -= yoffset;
if (fov <= 1.0)
fov = 1.0;
if (fov >= 45.0)
fov = 45.0;
}
當(dāng)滾輪往前的時候,yoffset為正,使得fov值變小,物體變大變精細(xì)。相反,當(dāng)滾輪往后的時候,yoffset為負(fù),使fov值變大,物體變小視野變廣。
當(dāng)然,必不可少的一項在之前注冊這個滾輪回調(diào)函數(shù)。
glfwSetScrollCallback(window, scroll_callback);
于是,我們的投影矩陣就變成了:
projection = glm::perspective(glm::radians((float)fov), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
非常簡單!編譯運行,你就能通過滾輪來變焦了。

如果在顯示上遇到麻煩,請參照源碼。
封裝類
之后的例子中,我們會經(jīng)常用到這個攝像機(jī)來觀察顯示效果,所以,將它封裝成類是聰明的做法。限于篇幅,就不再列出詳細(xì)的代碼了, 不過后面會給出源碼,有興趣的童鞋可以自己看內(nèi)部的實現(xiàn)。
檢查一遍類是否可用是一個非常好的習(xí)慣,攝像機(jī)類的源碼在這里,主文件的源碼在這里。
我們現(xiàn)在封裝的這個類可以滿足大部分需求,但它并不是沒有缺陷的。一個重要的問題就是萬向節(jié)死鎖。要解決這個問題,我們之后可以使用四元數(shù)的方法,現(xiàn)在先賣個關(guān)子。
總結(jié)
本章我們學(xué)了觀察矩陣的內(nèi)部原理,也通過一些三角學(xué)知識實現(xiàn)了一個簡單的FPS攝像機(jī),成果斐然!下一篇文章會對到目前為止所學(xué)到的內(nèi)容進(jìn)行總結(jié)梳理,畢竟知識不在多而在融會貫通。
參考資料:
www.learningopengl.com(非常好的網(wǎng)站,建議仔細(xì)學(xué)習(xí))