參考資料:OpenGL中文翻譯
概述
前面的教程中我們討論了觀察矩陣以及如何使用觀察矩陣移動場景(我們向后移動了一點)。OpenGL
本身沒有攝像機(Camera)的概念,但我們可以通過把場景中的所有物體往相反方向移動的方式來模擬出攝像機,產生一種我們在移動的感覺,而不是場景在移動。
本節我們將會討論如何在OpenGL
中配置一個攝像機,並且將會討論FPS
風格的攝像機,讓你能夠在3D場景中自由移動。我們也會討論鍵盤和鼠標輸入,最終完成一個自定義的攝像機類。
一、攝像機/觀察空間
當我們討論攝像機/觀察空間(Camera/View Space)
的時候,是在討論以攝像機的視角作為場景原點時場景中所有的頂點坐標:觀察矩陣把所有的世界坐標變換為相對於攝像機位置與方向的觀察坐標。要定義一個攝像機,我們需要:
- 它在世界空間中的位置
- 觀察的方向
- 一個指向它右測的向量以及一個指向它上方的向量
細心的讀者可能已經注意到我們實際上創建了一個三個單位軸相互垂直的、以攝像機的位置為原點的坐標系。
1. 攝像機位置
獲取攝像機位置很簡單。攝像機位置簡單來說就是世界空間中一個指向攝像機位置的向量。我們把攝像機位置設置為上一節中的那個相同的位置:
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
不要忘記正z軸是從屏幕指向你的,如果我們希望攝像機向后移動,我們就沿着z軸的正方向移動。
2. 攝像機方向
攝像機方向,即攝像機指向的方向。現在我們讓攝像機指向場景原點:(0, 0, 0)
。還記得如果將兩個矢量相減,我們就能得到這兩個矢量的差嗎?用場景原點向量減去攝像機位置向量的結果就是攝像機的指向向量。由於我們知道攝像機指向z軸負方向,但我們希望方向向量(Direction Vector)指向攝像機的z軸正方向。如果我們交換相減的順序,我們就會獲得一個指向攝像機正z軸方向的向量:
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
方向向量(Direction Vector)並不是最好的名字,因為它實際上指向從它到目標向量的相反方向(譯注:注意看前面的那個圖,藍色的方向向量大概指向z軸的正方向,與攝像機實際指向的方向是正好相反的)。
3. 右軸
我們需要的另一個向量是一個右向量(Right Vector),它代表攝像機空間的x軸的正方向。為獲取右向量我們需要先使用一個小技巧:先定義一個上向量(Up Vector)。接下來把上向量和第二步得到的方向向量進行叉乘。兩個向量叉乘的結果會同時垂直於兩向量,因此我們會得到指向x軸正方向的那個向量(如果我們交換兩個向量叉乘的順序就會得到相反的指向x軸負方向的向量):
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
4. 上軸
現在我們已經有了x軸向量和z軸向量,獲取一個指向攝像機的正y軸向量就相對簡單了:我們把方向向量和右向量進行叉乘:
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
使用這些攝像機向量我們就可以創建一個LookAt
矩陣了,它在創建攝像機的時候非常有用。
二、Look At矩陣
使用矩陣的好處之一是如果你使用3個相互垂直(或非線性)的軸定義了一個坐標空間,你可以用這3個軸外加一個平移向量來創建一個矩陣,並且你可以用這個矩陣乘以任何向量來將其變換到那個坐標空間。這正是LookAt矩陣所做的,現在我們有了3個相互垂直的軸和一個定義攝像機空間的位置坐標,我們可以創建我們自己的LookAt
矩陣了:
其中R
是右向量,U
是上向量,D
是方向向量P
是攝像機位置向量。注意,位置向量是相反的,因為我們最終希望把世界平移到與我們自身移動的相反方向。把這個LookAt
矩陣作為觀察矩陣可以很高效地把所有世界坐標變換到剛剛定義的觀察空間。LookAt
矩陣就像它的名字表達的那樣:它會創建一個看着(Look at)給定目標的觀察矩陣。
幸運的是,GLM
已經提供了這些支持。我們要做的只是(1)定義一個攝像機位置,(2)一個目標位置和(3)一個表示世界空間中的上向量的向量(我們計算右向量使用的那個上向量)。接着GLM
就會創建一個LookAt
矩陣,我們可以把它當作我們的觀察矩陣:
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));
參數:
- 攝像機位置
- 目標位置
- 世界坐標的上向量
在討論用戶輸入之前,我們先來做些有意思的事,把我們的攝像機在場景中旋轉。我們會將攝像機的注視點保持在(0, 0, 0)
。
我們需要用到一點三角學的知識來在每一幀創建一個x和z坐標,它會代表圓上的一點,我們將會使用它作為攝像機的位置。通過重新計算x和y坐標,我們會遍歷圓上的所有點,這樣攝像機就會繞着場景旋轉了。我們預先定義這個圓的半徑radius
,在每次渲染迭代中使用GLFW
的glfwGetTime
函數重新創建觀察矩陣,來擴大這個圓。
float radius = 10.0f;
float camX = sin(glfwGetTime()) * radius;
float camZ = cos(glfwGetTime()) * radius;
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0));
運行效果:

三、自由移動
讓攝像機繞着場景轉的確很有趣,但是讓我們自己移動攝像機會更有趣!首先我們必須設置一個攝像機系統,所以在我們的程序前面定義一些攝像機變量很有用:
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
LookAt
函數現在成了:
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
我們首先將攝像機位置設置為之前定義的cameraPos
。目標位置是當前的位置加上我們剛剛定義的方向向量。這樣能保證無論我們怎么移動,攝像機都會注視着目標方向。讓我們擺弄一下這些向量,在按下某些按鈕時更新cameraPos
向量。
我們已經為GLFW
的鍵盤輸入定義過一個processInput
函數了,我們來新添加幾個需要檢查的按鍵命令:
void processInput(GLFWwindow *window) {
...
float cameraSpeed = 0.05f; // adjust accordingly
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鍵的任意一個,攝像機的位置都會相應更新。如果我們希望向前或向后移動,我們就把位置向量加上或減去方向向量。如果我們希望向左右移動,我們使用叉乘來創建一個右向量(Right Vector),並沿着它相應移動就可以了。這樣就創建了使用攝像機時熟悉的橫移(Strafe)效果。
注意,我們對右向量進行了標准化。如果我們沒對這個向量進行標准化,最后的叉乘結果會根據cameraFront變量返回大小不同的向量。如果我們不對向量進行標准化,我們就得根據攝像機的朝向不同加速或減速移動了,但如果進行了標准化移動就是勻速的。
四、移動速度
目前我們的移動速度是個常量,理論上沒什么問題。但是實際情況下根據處理器的能力不同,有些人可能會比其他人每秒繪制更多幀,也就是以更高的頻率調用processInput
函數。結果就是,根據配置的不同,有些人可能移動很快,而有些人會移動很慢。當你發布你的程序的時候,你必須確保它在所有硬件上移動速度都一樣。
圖形程序和游戲通常會跟蹤一個時間差(Deltatime
)變量,它儲存了渲染上一幀所用的時間。我們把所有速度都去乘以deltaTime
值。結果就是,如果我們的deltaTime
很大,就意味着上一幀的渲染花費了更多時間,所以這一幀的速度需要變得更高來平衡渲染所花去的時間。使用這種方法時,無論你的電腦快還是慢,攝像機的速度都會相應平衡,這樣每個用戶的體驗就都一樣了。
這樣我們可以定義每秒運行的速度(即單位時間路程),這樣無論是幀數高還是低物體都可以以該速度運動。
例如:
定義速度為 1m/s
慢電腦為30幀,則每幀物體運動的距離為:\(\frac{1}{30}\)m,每秒運行1m
快電腦為60幀,則每幀物體運動的距離為:\(\frac{1}{60}\)m,每秒運行1m
我們跟蹤兩個全局變量來計算出deltaTime
值:
float deltaTime = 0.0f; // 當前幀與上一幀的時間差
float lastFrame = 0.0f; // 上一幀的時間
在每一幀中我們計算出新的deltaTime
以備后用:
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
現在我們有了deltaTime
,在計算速度的時候可以將其考慮進去了:
void processInput(GLFWwindow *window)
{
float cameraSpeed = 2.5f * deltaTime;
...
}
運行效果:

五、視角移動
為了能夠改變視角,我們需要根據鼠標的輸入改變cameraFront
向量。
1. 歐拉角
歐拉角(Euler Angle)
是可以表示3D空間中任何旋轉的3個值,由萊昂哈德·歐拉(Leonhard Euler)在18世紀提出。一共有3種歐拉角:俯仰角(Pitch)
、偏航角(Yaw)
和滾轉角(Roll)
,下面的圖片展示了它們的含義:
- 俯仰角是描述我們如何往上或往下看的角
- 偏航角表示我們往左和往右看的程度
- 滾轉角代表我們如何翻滾攝像機,通常在太空飛船的攝像機中使用。
每個歐拉角都有一個值來表示,把三個角結合起來我們就能夠計算3D空間中任何的旋轉向量了。
對於我們的攝像機系統來說,我們只關心俯仰角和偏航角,所以我們不會討論滾轉角。
給定一個俯仰角和偏航角,我們可以把它們轉換為一個代表新的方向向量的3D向量。俯仰角和偏航角轉換為方向向量的處理需要一些三角學知識。
如果我們想象自己在xz平面
上,看向y軸
,我們可以基於第一個三角形計算來計算它的長度/y方向的強度(Strength)(我們往上或往下看多少)。從圖中我們可以看到對於一個給定俯仰角的y值等於\(sin{(pitch)}\):
direction.y = sin(glm::radians(pitch)); // 注意我們先把角度轉為弧度
這里我們只更新了y值,仔細觀察x和z分量也被影響了。從三角形中我們可以看到它們的值等於:
length = cos(glm::radians(pitch));
其中length=x^2+z^2
看看我們是否能夠為偏航角找到需要的分量:
就像俯仰角的三角形一樣,我們可以看到x分量取決於cos(yaw)
的值,z值同樣取決於偏航角的正弦值。把這個加到前面的值中,會得到基於俯仰角和偏航角的方向向量:
direction.y = sin(glm::radians(pitch));
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 譯注:direction代表攝像機的前軸(Front),這個前軸是和本文第一幅圖片的第二個攝像機的方向向量是相反的
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
這樣我們就有了一個可以把俯仰角和偏航角轉化為用來自由旋轉視角的攝像機的3維方向向量了。你可能會奇怪:我們怎么得到俯仰角和偏航角?
2. 鼠標輸入
偏航角和俯仰角是通過鼠標(或手柄)移動獲得的:
- 水平的移動影響偏航角
- 豎直的移動影響俯仰角
它的原理就是:儲存上一幀鼠標的位置,在當前幀中我們當前計算鼠標位置與上一幀的位置相差多少。如果水平/豎直差別越大那么俯仰角或偏航角就改變越大,也就是攝像機需要移動更多的距離。
首先我們要告訴GLFW
,它應該隱藏光標,並捕捉(Capture)它。捕捉光標表示的是,如果焦點在你的程序上(譯注:即表示你正在操作這個程序,Windows中擁有焦點的程序標題欄通常是有顏色的那個,而失去焦點的程序標題欄則是灰色的),光標應該停留在窗口中(除非程序失去焦點或者退出)。我們可以用一個簡單地配置調用來完成:
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
函數作用:無論我們怎么去移動鼠標,光標都不會顯示了,它也不會離開窗口。對於FPS攝像機系統來說非常完美。
為了計算俯仰角和偏航角,我們需要讓GLFW
監聽鼠標移動事件。(和鍵盤輸入相似)我們會用一個回調函數來完成,函數的原型如下:
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
這里的xpos
和ypos
代表當前鼠標的位置。當我們用GLFW
注冊了回調函數之后,鼠標一移動mouse_callback
函數就會被調用:
glfwSetCursorPosCallback(window, mouse_callback);
在處理FPS風格攝像機的鼠標輸入的時候,我們必須在最終獲取方向向量之前做下面這幾步:
- 計算鼠標距上一幀的偏移量。
- 把偏移量添加到攝像機的俯仰角和偏航角中。
- 對偏航角和俯仰角進行最大和最小值的限制。
- 計算方向向量。
①獲取鼠標偏移量
我們必須先在程序中儲存上一幀的鼠標位置,我們把它的初始值設置為屏幕的中心(屏幕的尺寸是800x600):
float lastX = SCR_WIDTH/2, lastY = SCR_HEIGHT/2;
然后在鼠標的回調函數中我們計算當前幀和上一幀鼠標位置的偏移量:
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意這里是相反的,因為y坐標是從底部往頂部依次增大的
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;
注意我們把偏移量乘以了sensitivity
(靈敏度)值。如果我們忽略這個值,鼠標移動就會太大了;你可以自己實驗一下,找到適合自己的靈敏度值。
②把偏移量添加到攝像機的俯仰角和偏航角中
把偏移量加到全局變量pitch
和yaw
上:
yaw += xoffset;
pitch += yoffset;
③給攝像機添加一些限制
我們需要給攝像機添加一些限制,這樣攝像機就不會發生奇怪的移動了(這樣也會避免一些奇怪的問題)。對於俯仰角,要讓用戶不能看向高於89度的地方(在90度時視角會發生逆轉,所以我們把89度作為極限),同樣也不允許小於-89度。這樣能夠保證用戶只能看到天空或腳下,但是不能超越這個限制。我們可以在值超過限制的時候將其改為極限值來實現:
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
④得到真正的方向向量
glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);
計算出來的方向向量就會包含根據鼠標移動計算出來的所有旋轉了。由於cameraFront
向量已經包含在GLM
的lookAt
函數中,我們這就沒什么問題了。
⑤細節調整
如果你現在運行代碼,你會發現在窗口第一次獲取焦點的時候攝像機會突然跳一下。這個問題產生的原因是,在你的鼠標移動進窗口的那一刻,鼠標回調函數就會被調用,這時候的xpos
和ypos
會等於鼠標剛剛進入屏幕的那個位置。這通常是一個距離屏幕中心很遠的地方,因而產生一個很大的偏移量,所以就會跳了。我們可以簡單的使用一個bool
變量檢驗我們是否是第一次獲取鼠標輸入,如果是,那么我們先把鼠標的初始位置更新為xpos和ypos值,這樣就能解決這個問題;接下來的鼠標移動就會使用剛進入的鼠標位置坐標來計算偏移量了:
if(firstMouse) // 這個bool變量初始時是設定為true的
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
最后的代碼應該是這樣的:
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
if(firstMouse)
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05;
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(front);
}
3. 縮放
作為我們攝像機系統的一個附加內容,我們還會來實現一個縮放(Zoom)接口。在之前的教程中我們說視野(Field of View)或fov定義了我們可以看到場景中多大的范圍。當視野變小時,場景投影出來的空間就會減小,產生放大(Zoom In)了的感覺。我們會使用鼠標的滾輪來放大。與鼠標移動、鍵盤輸入一樣,我們需要一個鼠標滾輪的回調函數:
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
if(fov >= 1.0f && fov <= 45.0f)
fov -= yoffset;
if(fov <= 1.0f)
fov = 1.0f;
if(fov >= 45.0f)
fov = 45.0f;
}
當滾動鼠標滾輪的時候,yoffset
值代表我們豎直滾動的大小。當scroll_callback
函數被調用后,我們改變全局變量fov
變量的內容。因為45.0f
是默認的視野值,我們將會把縮放級別(Zoom Level)
限制在1.0f
到45.0f
。
我們現在在每一幀都必須把透視投影矩陣上傳到GPU,但現在使用fov
變量作為它的視野:
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
注冊鼠標滾輪的回調函數:
glfwSetScrollCallback(window, scroll_callback);
4. 上升和下降
例如《我的世界》這樣的游戲,在建造模式中角色可以進行飛行,而此時可以點擊鍵盤的空格鍵實現角色的上升運動,我們這里也添加這個功能,只需在鍵盤事件函數中添加:
if (glfwGetKey(objectPool->window, GLFW_KEY_Q) == GLFW_PRESS) {
cameraPos += cameraUp * cameraSpeed;
}
if (glfwGetKey(objectPool->window, GLFW_KEY_E) == GLFW_PRESS) {
cameraPos -= cameraUp * cameraSpeed;
}
即點擊Q
鍵上升,E
鍵下降。