http://huangwei.pro/2015-09/modern-opengl4/

本篇教程中,我們會鞏固上一篇所提到的矩陣和相機知識,并使用tdogl::Camera
類來實現第一人稱射擊類型的相機。然后,我們會將相機與鍵盤和鼠標掛鉤,使得我們可以移動和瀏覽3D場景。這里會學一些向量數學,還有上一篇沒提到的逆矩陣。
獲取代碼
所有例子代碼的zip打包可以從這里獲取:https://github.com/tomdalling/opengl-series/archive/master.zip。
這一系列文章中所使用的代碼都存放在:https://github.com/tomdalling/opengl-series。你可以在頁面中下載zip,加入你會git的話,也可以復制該倉庫。
本文代碼你可以在source/04_camera
目錄里找到。使用OS X系統的,可以打開根目錄里的opengl-series.xcodeproj
,選擇本文工程。使用Windows系統的,可以在Visual Studio 2013里打開opengl-series.sln
,選擇相應工程。
工程里已包含所有依賴,所以你不需要再安裝或者配置額外的東西。如果有任何編譯或運行上的問題,請聯系我。
向量理論
在上一篇學了矩陣理論后,你以為數學理論課就結束了?想得太美了,現在下一部分就來了:向量。正統的理解認為向量是3D編程的基礎。后面我會展示些代碼,是用鍵盤來進行向量運算,讓相機可以在不同方向上移動。
在3D中(2D中也一樣),向量經常用來表示一些不同的東西,比如:
- 位置(即,坐標)
- 位移(比如,移動)
- 方向(比如,南北,上下)
- 速度(比如,車的速度和方向)
- 加速(比如,重力)
你可能注意到了上面所提的一些概念都是通常是用來實現物理引擎的。我們在本文中不會實現所有的物理,但為了更好的理解向量,第一步讓我們來一些物理教學。
什么是向量?一種偽數學的定義上來說,一個向量(vector)就是幅度(magnitude)加上方向。它能向上,向下,往左,往右,朝北,朝西南等等。你能用3D向量來表示任何一個你指向的方向。向量的另一部分,幅度,表示向量的長度或者大小。
向量最簡單的可視化方式就是繪制它,一般向量都會被繪制為箭頭。箭頭所指的方向就是向量的方向,箭頭的長度就是幅度。下面的圖是一個2D向量,但2D的理論同樣能應用到3D上。

下面用例子來說明向量代表的不同含義。
| 方向 | 幅度 | 含義 |
---|
往北5千米 | 北 | 5千米 | 位置 |
頭上5厘米 | 上 | 5厘米 | 位置 |
以50千米每小時開往西湖 | 西湖方向 | 50千米/每小時 | 速度 |
地球引力為9.8m/s2 | 往地球質心 | 9.8m/s2 | 加速 |
當編碼時,向量只是一組數字。每個數字都是向量的“一維”。比如,一個三維3D向量就是有3個數字的數組,2D向量是有2個數字。因為我們是在3D中進行工作,所以大部分情況只要處理3D向量,但我們也需要用到4D。無論何時我說“向量”,那意味著是3D向量。我們使用GLM的向量數學庫,2D,3D,4D的類型分別為glm::vec2
,glm::vec3
,glm::vec4
。
3D向量表示頂點,坐標或者位置相當簡單。3D向量的3個維度分別是X,Y,Z的值。當向量表示位置,方向和幅度時,都是從原點(0,0,0)開始計算的。比如,假設一個物體的XYZ坐標為(0,2,0),則它的幅度是2,方向為“沿Y軸向上”。
負向量
當你要將向量取負時,就是保持相同的幅度,但將方向變成方向。
比如:

A=向北5千米
-A=向南5千米
如果相機的方向是往右的,我們可以使用負向量來算出相機往左的方向。就像這樣:
glm::vec3 rightDirection = gCamera.right(); glm::vec3 leftDirection = -rightDirection; //vector negation
標量乘法
當你將向量乘上一個數值時,新向量的結果表示相同的方向,但幅度被擴大了相應倍數。這個數值被稱為“標量”,這就是為何該乘法被稱為“標量乘法”。
比如:

A=向北5千米
0.5 × A=向北2.5千米
2 × A=向北10千米
我們可以使用標量乘法來計算基于“移動速度”的相機位置,像這樣:
const float moveSpeed = 2.0; //units per second float distanceMoved = moveSpeed * secondsElapsed; glm::vec3 forwardDirection = gCamera.forward(); glm::vec3 displacement = distanceMoved * forwardDirection; //scalar multiplication
向量加法
向量加法在2D圖形表現下最容易理解。對兩個向量進行加法,就是將它們的頭部(箭頭一段)連接尾部(非箭頭一段)。加法順序不重要。它的結果就是,從第一個向量尾部走向另外一個向量的頭部。

注意,即使這些向量看上去是在不同的位置上,但結果向量的幅度(長度)和方向不會改變。請記住,向量只有一個方向和一個幅度。它們沒有起始點,所以它們可以在任意不同位置上,但還是相等的。
比如:
A = 往北1千米
B = 往西1千米
A + B = 往西北1.41千米
向量減法相當于是加上一個負向量,比如:
A = 往北1千米
B = 往西1千米
A - B = 往西北1.41千米
A + (-B) = 往西北1.41千米
我們使用向量加法來計算出相機位移后的的新位置,像這樣:
glm::vec3 displacement = gCamera.forward() * moveSpeed * secondsElapsed; glm::vec3 oldPosition = gCamera.position(); glm::vec3 newPosition = oldPosition + displacement; //vector addition gCamera.setPosition(newPosition);
單位向量
單位向量是幅度為1的向量。它們經常被用來表示方向。
當一個向量是用來表示方向時,它的幅度就沒啥用處。即使這樣,我們還是將它的幅度設為1,是為了計算時更方便一些。
當你在單位向量上使用標量乘法時,它的方向仍然不變,但幅度會被設為標量的值。因此,你將一個單位向量乘上5后,新的向量的幅度就是5。假如你乘上123,那幅度也就是123?;旧线@允許我們設置任意一個向量的幅度,而不會更改它的方向。
讓我們對相機進行往左移動12單位的操作。我們先設置一個方向為左的單位向量,然后使用標量乘法將它的幅度設為12,最后使用它來計算出新位置。代碼看上去應該是這樣的:
// `gCamera.right()` returns a unit vector, therefore `leftDirection` will also be a unit vector. // Negation only affects the direction, not the magnitude. glm::vec3 leftDirection = -gCamera.right(); //`displacement` will have a magnitude of 12 glm::vec3 displacement = leftDirection * 12; //`newPosition` will be 12 units to the left of `oldPosition` glm::vec3 newPosition = oldPosition + displacement;
任何一個向量都能變為單位向量。這個操作叫做單位化。我們可以用GLM來單位化一個向量:
glm::vec3 someRandomVector = glm::vec3(123,456,789); glm::vec3 unitVector = glm::normalize(someRandomVector);
tdogl::Camera類
恭喜你看到這兒了!現在你已經有足夠的向量知識了,來,讓我們開始編碼。
tdogl::Camera
類的接口在這里,實現代碼在這里。
在前面文章中我們在OpenGL中用矩陣來實現相機。tdogl::Camera
類可以基于各種屬性來創建矩陣,比如:
- 相機位置
- 相機朝向(方向)
- 縮放(視野)
- 最大和最小可視距離(遠近平面)
- 視口/窗口縱橫比
上面的每個屬性都有各自的設置和獲取接口。前文已經介紹過了。
現在讓我們用matrix
和orientation
方法來實現如何讓這所有屬性組合成一個矩陣。
glm::mat4 Camera::matrix() const { glm::mat4 camera = glm::perspective(_fieldOfView, _viewportAspectRatio, _nearPlane, _farPlane); camera *= orientation(); camera = glm::translate(camera, -_position); return camera; } glm::mat4 Camera::orientation() const { glm::mat4 orientation; orientation = glm::rotate(orientation, _verticalAngle, glm::vec3(1,0,0)); orientation = glm::rotate(orientation, _horizontalAngle, glm::vec3(0,1,0)); return orientation; }
我們可以看到,最終的相機矩陣是由四個不同的變換組成。按順序是:
- 移動,基于相機位置
- 旋轉,基于相機水平(左/右)轉角
- 旋轉,基于相機垂直(上/下)轉角
- 透視,基于視野,近平面,遠平面和縱橫比
假如你覺得這順序是反的,那請記住矩陣乘法是從右往左,代碼上順序是從底往上。
注意,移動用了相機的負位置。這里再次用前文提到的方式,我們可以讓3D場景往后來實現相機往前走。向量為負時會反轉其方向,所以“往前”就變成“往后”。
tdogl::Camera
類還有其它方法來返回單位向量:上
,右
和前
。我們需要從鍵盤獲取消息來實現相機移動。
相機方位矩陣求逆
讓我來看下tdogl::Camera::up
方法的實現,這里有兩個東西我們還沒有提及。
glm::vec3 Camera::up() const { glm::vec4 up = glm::inverse(orientation()) * glm::vec4(0,1,0,1); return glm::vec3(up); }
我們看到它使用了glm::inverse
方法。從上一篇文章中,我們知道矩陣能對坐標進行變換。在這里,我們還需要對坐標進行“反變換”,使得我們能獲得矩陣乘法變換前的坐標。為了實現這個目的,我們需要計算逆矩陣。逆矩陣是一個矩陣,完全相反于另外一個矩陣,這意味著它能撤銷另外一個矩陣的變換。比如,矩陣A
是繞著Y軸旋轉90°,那矩陣A
的逆矩陣就是繞著Y軸旋轉-90°。
當相機的方向改變時,“向上”的方向也隨之改變。比如,想象下有個箭頭指向你的頭頂,假如你旋轉你的頭往地上看,那箭頭就是向前傾斜,假如你往天上看,那箭頭是向后傾斜的。如果你往前看,就是你的頭“不旋轉”,那箭頭就是筆直向上。我們用“筆直向上”的單位向量(0,1,0)來表示相機的向上方向,“不旋轉”使用相機方位矩陣的逆矩陣。另外一種解釋,在相機旋轉后,向上方向總是為(0,1,0),所以我們要將逆旋轉乘上(0,1,0),這就能得到相機旋轉前的向上方向。
(0,1,0)是單位向量,當你旋轉一個單位向量結果還是一個單位向量。假如結果不是單位向量,你應該使用glm::normalize
來單位化。
計算相機的前
和右
方向是同樣的方式。
你可能注意到了這里用了一個4D向量glm::vec4
。前文解釋過,4x4 矩陣(glm::mat4
)需要一個4D向量來進行矩陣乘法,使用glm::vec3
會導致編譯錯誤。只要把3D向量(0,1,0)變成4D向量(0,1,0,1)就可以進行矩陣乘法了,計算完成后我們再將4D向量變回3D向量。
整合tdogl::Camera類
現在我們開始使用tdogl:Camera
類。
在之前的文章中,我們分別設置了投影矩陣和相機矩陣兩個著色器變量。在本文中,tdogl::Camera
合并了這兩個矩陣,所以讓我們移除projection
著色器變量,只用camera
變量就足夠了。下面是頂點著色器的更新:
#version 150 uniform mat4 camera; uniform mat4 model; in vec3 vert; in vec2 vertTexCoord; out vec2 fragTexCoord; void main() { // Pass the tex coord straight through to the fragment shader fragTexCoord = vertTexCoord; // Apply all matrix transformations to vert gl_Position = camera * model * vec4(vert, 1); }
現在我們將tdogl::Camera
整合到main.cpp
中。首先包含頭文件:
#include "tdogl/Camera.h"
然后聲明全局變量:
在前一篇文章中,相機和投影矩陣是不會改變的,所以在LoadShaders
函數中設置一次就好了。但在本文中,因為我們需要用鼠標和鍵盤來控制,所以設置相機矩陣要放在Render
函數中并每幀都要設置一下。首先讓我們移除舊代碼:
static void LoadShaders() { std::vector<tdogl::Shader> shaders; shaders.push_back(tdogl::Shader::shaderFromFile(ResourcePath("vertex-shader.txt"), GL_VERTEX_SHADER)); shaders.push_back(tdogl::Shader::shaderFromFile(ResourcePath("fragment-shader.txt"), GL_FRAGMENT_SHADER)); gProgram = new tdogl::Program(shaders); // the commented-out code below was removed /* gProgram->use(); //set the "projection" uniform in the vertex shader, because it's not going to change glm::mat4 projection = glm::perspective<float>(50.0, SCREEN_SIZE.x/SCREEN_SIZE.y, 0.1, 10.0); //glm::mat4 projection = glm::ortho<float>(-2, 2, -2, 2, 0.1, 10); gProgram->setUniform("projection", projection); //set the "camera" uniform in the vertex shader, because it's also not going to change glm::mat4 camera = glm::lookAt(glm::vec3(3,3,3), glm::vec3(0,0,0), glm::vec3(0,1,0)); gProgram->setUniform("camera", camera); gProgram->stopUsing(); */ }
然后,在Render
函數中設置camera
著色器變量:
// draws a single frame static void Render() { // clear everything glClearColor(0, 0, 0, 1); // black glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // bind the program (the shaders) gProgram->use(); // set the "camera" uniform gProgram->setUniform("camera", gCamera.matrix());
gCamera.matrix()
函數返回的是一個glm::mat4
, 并且setUniform
函數使用了glUniformMatrix4fv
來設置頂點著色器中的相機矩陣uniform變量。
在AppMain
函數中設置相機的初始化位置和視窗縱橫比。
gCamera.setPosition(glm::vec3(0,0,4)); gCamera.setViewportAspectRatio(SCREEN_SIZE.x / SCREEN_SIZE.y);
其余相機屬性都留成默認值。
你現在運行程序,會看到上次實現的旋轉立方體。下一步就讓我們用鼠標和鍵盤來控制相機。
鍵盤輸入
我們先來實現鍵盤控制。每次我們更新屏幕時,我們先檢查'W','A','S'或'D'按鍵是否被按下,如果有觸發那就稍微移動下相機。函數glfwGetKey
返回一個布爾值來表示這個按鍵是否按下。新的Update
函數看上去是這樣的:
// update the scene based on the time elapsed since last update void Update(float secondsElapsed) { //rotate the cube const GLfloat degreesPerSecond = 180.0f; gDegreesRotated += secondsElapsed * degreesPerSecond; while(gDegreesRotated > 360.0f) gDegreesRotated -= 360.0f; //move position of camera based on WASD keys const float moveSpeed = 2.0; //units per second if(glfwGetKey(gWindow, 'S')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * -gCamera.forward()); } else if(glfwGetKey(gWindow, 'W')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * gCamera.forward()); } if(glfwGetKey(gWindow, 'A')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * -gCamera.right()); } else if(glfwGetKey(gWindow, 'D')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * gCamera.right()); } }
我們先忽略立方體的旋轉。
當S
鍵被按下時,我們可以看得更近些:
gCamera.offsetPosition(secondsElapsed * moveSpeed * -gCamera.forward());
這一行代碼做了好多事,讓我們用更容易懂的方式重寫一遍,新的函數叫MoveCameraBackwards
。
void MoveCameraBackwards(float secondsElapsed) { //TODO: finish writing this function }
向后是一個方向,所以應該是個單位向量。在相機類中沒有backward
函數,但它有個forward
函數。向后就是向前的反方向,所以我們只要對向前的單位向量取負數即可。
void MoveCameraBackwards(float secondsElapsed) { //`direction` is a unit vector, set to the "backwards" direction glm::vec3 direction = -gCamera.forward(); //TODO: finish writing this function }
然后,我們應該知道將相機移多遠。我們有相機的移動速度moveSpeed
,我們還知道從上一幀到現在過去了多少時間secondsElapsed
。對這兩個值進行乘法,就能得到相機移動的距離。
void MoveCameraBackwards(float secondsElapsed) { //`direction` is a unit vector, set to the "backwards" direction glm::vec3 direction = -gCamera.forwards(); //`distance` is the total distance to move the camera float distance = moveSpeed * secondsElapsed; //TODO: finish writing this function }
現在,我們知道了移動的距離和方向,我們就能構造一個位移向量。它的幅度就是distance
,它的方向就是direction
。因為direction
是個單位向量,我們可以用標量乘法來設置幅度。
void MoveCameraBackwards(float secondsElapsed) { //`direction` is a unit vector, set to the "backwards" direction glm::vec3 direction = -gCamera.forwards(); //vector negation //`distance` is the total distance to move the camera float distance = moveSpeed * secondsElapsed; //`displacement` is a combination of `distance` and `direction` glm::vec3 displacement = distance * direction; //scalar multiplication //TODO: finish writing this function }
最后,我們移動(或者說是置換)相機當前位置。用向量加法即可。最基礎的公式newPosition = oldPosition + displacement
。
void MoveCameraBackwards(float secondsElapsed) { //`direction` is a unit vector, set to the "backwards" direction glm::vec3 direction = -gCamera.forwards(); //vector negation //`distance` is the total distance to move the camera float distance = moveSpeed * secondsElapsed; //`displacement` is a combination of `distance` and `direction` glm::vec3 displacement = distance * direction; //scalar multiplication //change the position of the camera glm::vec3 oldPosition = gCamera.position(); glm::vec3 newPosition = oldPosition + displacement; //vector addition gCamera.setPosition(newPosition); }
完成了!MoveCameraBackwards
函數這么多行代碼跟這一行代碼是一樣的:
gCamera.offsetPosition(secondsElapsed * moveSpeed * -gCamera.forward());
offsetPosition
函數做的就是向量加法,它將位移向量作為參數傳入。讓我們使用那一行代碼來替換MoveCameraBackwards
函數,因為簡潔就是美。
其余按鍵的工作方式都是相同的,無非是方向不同而已。讓我們再添加Z
和X
鍵來實現相機上和下。
if(glfwGetKey(gWindow, 'Z')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * -glm::vec3(0,1,0)); } else if(glfwGetKey(gWindow, 'X')){ gCamera.offsetPosition(secondsElapsed * moveSpeed * glm::vec3(0,1,0)); }
注意,為什么這里用向量(0,1,0)而不是gCamera.up()
。記住,“向上”方向會隨著相機方向而改變。假如相機看地上,“向上”指的是向前,假設相機看天上,“向上”指的是向后。這并不是我想實現的行為,我希望的是“筆直向上”的方向(0,1,0),不依賴于相機的方向。
現在當你運行程序,你能使用W
, A
, S
, D
, X
,和Z
鍵來向前移動,向左移動,向后移動,向右移動,向上移動和向下移動。觀察時不會因為相機移動而改變方向,這個將留個鼠標來控制。
鼠標輸入
此時,我們的窗口還無法捕捉鼠標消息。你能看到鼠標在窗口上移來移去。我希望它消失,并且不希望它移出窗口。為了實現這個,我們要改下GLFW的設置。
在我們捕獲鼠標之前,讓我們先實現用取消鍵(Esc)退出程序。我不想再點擊關閉按鈕了,因為鼠標隱藏,并且無法離開窗口。讓我們在AppMain
主循環下放加上些代碼:
// run while the window is open double lastTime = glfwGetTime(); while(!glfwWindowShouldClose(gWindow)){ // process pending events glfwPollEvents(); // update the scene based on the time elapsed since last update double thisTime = glfwGetTime(); Update((float)(thisTime - lastTime)); lastTime = thisTime; // draw one frame Render(); // check for errors GLenum error = glGetError(); if(error != GL_NO_ERROR) std::cerr << "OpenGL Error " << error << std::endl; //exit program if escape key is pressed if(glfwGetKey(gWindow, GLFW_KEY_ESCAPE)) glfwSetWindowShouldClose(gWindow, GL_TRUE); }
當我們用glfwCreateWindow
打開窗口這樣設置時,就可以捕獲鼠標了:
// GLFW settings glfwSetInputMode(gWindow, GLFW_CURSOR, GLFW_CURSOR_DISABLED); glfwSetCursorPos(gWindow, 0, 0);
這段代碼讓鼠標消失了,并且將它移動到了像素坐標(0,0)。在Update
中,我們會獲取鼠標位置來更新相機,更新完后將鼠標坐標再次設為(0,0)。這種方式可以很方便的看出每幀鼠標移動了多少,還要在當鼠標要移出窗口時停住它。在Update
函數下面添加以下代碼:
//rotate camera based on mouse movement const float mouseSensitivity = 0.1f; double mouseX, mouseY; glfwGetCursorPos(gWindow, &mouseX, &mouseY); gCamera.offsetOrientation(mouseSensitivity * (float)mouseY, mouseSensitivity * (float)mouseX); glfwSetCursorPos(gWindow, 0, 0); //reset the mouse, so it doesn't go out of the window
鼠標的坐標單位是像素,但相機方向是基于兩個角度。這就是為何我們使用mouseSensitivity
變量來將像素轉為角度。越大的鼠標靈敏度,相機轉向的越快,越小的靈敏度,轉向的越慢。靈敏度設為0.1f
的含義就是每10像素就旋轉1°。
offsetOrientation
函數類似于offsetPosition
函數,它會使用水平和垂直角度來更新相機方向。
好了!基本到這就完成了。你現在運行程序的話,你能繞著飛行并且幾乎能觀察任意方向。立方體的旋轉動畫可能會讓你在環繞時失去方向感,我們可以關閉它。
用鼠標滾輪控制視野
就像蛋糕上的糖衣一樣,我們可以滾動鼠標或者在觸摸板上滑動來實現相機鏡頭的視野縮放。上篇文章我們已經解釋過視野的概念了。
我們使用同樣的方式來使用鼠標位置,并且每幀重置滾動值。首先我們創建一個全局變量來保存滾動值:
使用GLFW來接受滾輪消息,首先我們得創建個回調:
// records how far the y axis has been scrolled void OnScroll(GLFWwindow* window, double deltaX, double deltaY) { gScrollY += deltaY; }
然后我們用GLFW在AppMain
中注冊下回調:
glfwSetScrollCallback(gWindow, OnScroll);
當每幀我們渲染的時候,我們使用gScrollY
值來更改視野。代碼放在Update
函數的下放:
const float zoomSensitivity = -0.2f; float fieldOfView = gCamera.fieldOfView() + zoomSensitivity * (float)gScrollY; if(fieldOfView < 5.0f) fieldOfView = 5.0f; if(fieldOfView > 130.0f) fieldOfView = 130.0f; gCamera.setFieldOfView(fieldOfView); gScrollY = 0;
zoomSensitivity
常量類似mouseSensitivity
常量。視野取值范圍是0°到180°,但假如你設置的值離上下限很近的話,3D場景看上去會很奇怪,所以我們限制這個值范圍在5°到130°。類似鼠標位置的方法,我們在每幀之后設置gScrollY = 0
。
下篇預告
下一篇文章,我們會重構代碼來實現最最基本的“引擎”。我們會將代碼分為資產(資源)和實例,類似典型的3D引擎,可以生成有多個略微不同的木箱子的3D場景。
更多資源