STEP1 投影(Projection)
- 将六个面颜色不同的cube放置在 (-1.5, 0.5, -1.5) 位置
实现很简单,在画出cube后将其对应的model矩阵处理为:
model = glm::translate(model, glm::vec3(-1.5, 0.5, -1.5));
即可,显示如下:
接着获取不同的投影情况。上一次的作业中只是简单应用了坐标系统的转换,没有做展开的实验,这次实验的主要目的也是对view、projection矩阵更进一步的了解。
下面要讨论的两个投影实际上都是对projection矩阵的修改。
- 正交投影(正射投影)
类似于初中学过的正投影,正射投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉:
定义了可见的坐标,它由由宽、高、近(Near)平面和远(Far)平面,需要注意的是正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,因为每个向量的w分量都没有进行改变;如果w分量等于1.0,透视除法则不会改变这个坐标。
具体实现如下:
if (show_ortho)
{
ImGui::SliderFloat("width", &width, 0.0f, 16.0f);
ImGui::SliderFloat("height", &height, 0.0f, 12.0f);
model = glm::translate(model, glm::vec3(-1.5, 0.5, -1.5));
view = glm::translate(view, glm::vec3(4.0f, 3.0f,0.0f));
projection = glm::ortho(0.0f,width, 0.0f, height, 0.1f, 100.0f);
...
}
这样处理的结果如下图:
添加滑块对宽、高以及远平面进行处理,发现宽、高会影响投影的面积大小,而如果近平面过远或远平面过近则会导致视野中没有投影的结果。下图为height,width增长一倍的情况:
如果height,width与window的尺寸比例不一致(800:600),那么得到的投影会变成矩形,(对边仍保持平行):
- 透视投影
透视投影与我们平时所观察到的现象一致:,由于透视,两条平行线在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。
与正射投影不同,透视投影创建的平截头体如下图所示:
透视投影矩阵与正射矩阵不同的一点是修改了每个顶点坐标的w值,使得离观察者越远的顶点坐标w分量越大。被变换到裁剪空间的坐标都会在-w到w的范围之间(任何大于这个范围的坐标都会被裁剪掉)。OpenGL要求所有可见的坐标都落在-1.0到1.0范围内,作为顶点着色器最后的输出,因此,一旦坐标在裁剪空间内之后,透视除法就会被应用到裁剪空间坐标上。
具体实现如下:
if (show_perspective) {
model = glm::translate(model, glm::vec3(-1.5, 0.5, -1.5));
view = glm::translate(view, glm::vec3(-0.5f, 0.5f, -1.5f));
projection = glm::perspective(glm::radians(45.0f)*100, (float)SRC_WIDTH / (float)SRC_HEIGHT, 0.1f, 100.0f);
...
}
perspective函数的第一个参数定义了fov的值,它表示的是视野(Field of View),并且设置了观察空间的大小。如果想要一个真实的观察效果,它的值通常设置为45.0f,这里由于观察的问题,需要将其乘以100才能够看到正常大小的立方体。。第二个参数设置了宽高比,由视口的宽除以高所得。第三和第四个参数设置了平截头体的近和远平面。我们通常设置近距离为0.1f,而远距离设为100.0f。
透视投影的结果如下图所示:
可以明显看到绿色的一面上下两条边是不平行的。
STEP2 视角变换 。把 cube 放置在 (0, 0, 0) 处,做透视投影,使摄像机围绕 cube 旋转,并且时刻看着 cube 中心
这一步为了使实验效果更明显选择跟OpenGL官方教程一样绘制多个立方体(中间的立方体即作业中要求的立方体)。
上一步中变动的是projection矩阵,而这一步的主角是view矩阵。
摄像机(观察空间)的几个属性
观察空间建立了一个观察矩阵,将所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。显然要定义一个摄像机我们需要一个原点(摄像机位置)、摄像机观察的方向、以及以摄像机为原点的坐标系(一个指向它右侧的向量以及一个指向它上方的向量)。
LookAt矩阵
LookAt矩阵是根据相机的位置、观察目标的位置以及up向量来创建观察矩阵的函数,实际上是通过观察目标位置和相机位置的差来求得摄像机的观察方向(右向量)。它实现的主要功能是使摄像机能固定地看着观察目标,OpenGL中实现的方式如下:
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)); // 上向量
而在这里我们希望实现的是摄像机能够围绕着目标旋转而将摄像机的观察点固定在摄像机的位置上,这就需要对第一个参数(摄像机的位置)进行修改。利用三角函数的相关知识来给每一帧创建一个x和z坐标,这个坐标代表所期望的摄像机圆形轨道上的一点,即当前帧对应的摄像机位置。具体的实现方式如下:
float radius = 200.0f;
float camX = sin(glfwGetTime())*radius;
float camZ = cos(glfwGetTime())*radius;
model = glm::translate(model, glm::vec3(-1.5, 0.5, -1.5));
view = glm::lookAt(glm::vec3(camX, 0.0f, camZ), glm::vec3(-1.5, 0.5, -1.5), glm::vec3(0.0f, 1.0f, 0.0f));
其中radius为期望的圆的半径,根据情况进行适当的调整。
实现结果如下图所示:
STEP3 GUI的添加
这次作业的GUI多添加了一步,即如果要显示视角变换或者显示简单的FPS场景,会选择绘制多个cube来获取更好的观察效果:
if (view_change || mouse) {
for (unsigned int i = 0; i < 10; i++)
{
// 每个cube由不同的model模型
glm::mat4 model;
model = glm::translate(model, cubePositions[i]);
float angle = 20.0f * i;
model = glm::rotate(model, glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
}
else
{
glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
STEP4 思考
通常情况下我们是将model矩阵和view矩阵分开来考虑的。
Bonus 简单的类FPS场景实现
实现一个camera类(myCamera.h),当键盘输入w,a,s,d
时能够前后左右移动;当移动鼠标能够改变我们的视角。
- camera类的public成员
// 摄像机属性(摄像机位置、朝向相关的Front向量、上向量、右向量)
glm::vec3 Position;
glm::vec3 Front;
glm::vec3 Up;
glm::vec3 Right;
// 初始化与Y轴正方向同向
glm::vec3 WorldUp;
...
- 简单的设想
显然这里我们需要处理的包括键盘的输入和鼠标输入。如果对FPS游戏有一定的了解,很容易就能得到我们需要的变量:第一人称的移动速度(SPEED)以及鼠标的灵敏度(SENSITIVITY)。
// public 添加成员
float MovementSpeed;
float MouseSensitivity;
SPEED决定视角移动的速度,而鼠标灵敏度的大小决定了鼠标移动相同的角度时,游戏画面转换角度的幅度。
- 自由移动的实现(键盘输入的处理)
这一步较为简单,针对不同的输入更改摄像机的位置即可。
首先获取并初步处理键盘输入。在前面实现过的handleInput函数中增加对w,a,s,d
四个输入键的处理即可:
void handleInput(GLFWwindow *window) {
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
camera.ProcessKeyboard(FORWARD, deltaTime);
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
camera.ProcessKeyboard(BACKWARD, deltaTime);
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
camera.ProcessKeyboard(LEFT, deltaTime);
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
camera.ProcessKeyboard(RIGHT, deltaTime);
}
接着完善handleInput中调用的函数——Camera类中的ProcessKey函数:
// direction是handleInput的输出方向,deltaTime对应当前帧与前一帧之间的时间差
void ProcessKeyboard(CameraMovement direction, float deltaTime)
{
float velocity = MovementSpeed * deltaTime;
if (direction == FORWARD)
Position += Front * velocity;
if (direction == BACKWARD)
Position -= Front * velocity;
if (direction == LEFT)
Position -= Right * velocity;
if (direction == RIGHT)
Position += Right * velocity;
}
直接修改Position的值即可。
- 视角转换
与STEP2中实现的围绕目标旋转摄像机不同,这里我们需要摄像机能够在搭建的空间内自由移动,所以摄像机与场景内(我们所希望看到的)物体之间的相对位置求解是十分重要的。这里需要引入俯仰角(PITCH)和偏航角(YAW)的概念。
PITCH AND YAW
// public中设置俯仰角和偏航角
float Yaw;
float Pitch;
(滚转角决定视角如何进行翻转,此处不进行讨论)
俯仰角是描述视线与水平线夹角的角度,即可以理解为坐标系中向量与XOZ平面的夹角。偏航角是视线(摄像机的朝向)与XOY方向的夹角。那么对于坐标系中的摄像机,它的Front变量可以通过以下方法计算得到:
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));
Front = glm::normalize(front);
而Front向量更新后,我们可以根据世界坐标系的up向量求得观察坐标系的right向量,再进一步求出观察坐标系的up向量 :
Right = glm::normalize(glm::cross(Front, WorldUp));
Up = glm::normalize(glm::cross(Right, Front));
那么当视角(鼠标)移动的时候我们应该如何变更俯仰角和偏航角呢?
首先我们依旧需要构造一个调用函数来调回鼠标的变化:
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,lastY
lastX = xpos;
lastY = ypos;
camera.ProcessMouseMovement(xoffset, yoffset);
}
下面需要实现对鼠标输入的处理函数ProcessMouseMovement
:
void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true)
{
// 鼠标灵敏度决定角度变换的大小
xoffset *= MouseSensitivity;
yoffset *= MouseSensitivity;
// 偏航角和俯仰角的更新
Yaw += xoffset;
Pitch += yoffset;
// 处理边界问题
if (constrainPitch)
{
if (Pitch > 89.0f)
Pitch = 89.0f;
if (Pitch < -89.0f)
Pitch = -89.0f;
}
// 更新相机的位置、视角变换,即上面所列出的对于Front等向量的求解
updateCameraVectors();
}
- 获取lookat矩阵
在对键盘输入以及鼠标输入进行处理后,获取lookat矩阵已经很简单了:
// 获取lookat矩阵
glm::mat4 GetViewMatrix()
{
return glm::lookAt(Position, (Position + Front), Up);
}
接着在main函数中进行调用即可:
if (mouse) {
glfwSetCursorPosCallback(window, mouse_callback);
glfwSetScrollCallback(window, scroll_callback);
// tell GLFW to capture our mouse
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
handleInput(window);
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
view = camera.GetViewMatrix();
projection = glm::perspective(glm::radians(camera.Zoom)*100, (float)SRC_WIDTH / (float)SRC_HEIGHT, 0.1f, 1000.0f);
show_cubic = false;
show_ortho = false;
show_perspective = false;
view_change = false;
}
- 实验结果
如下图所示,展示的顺序是从粉色面开始逆时针绕cube一圈,然后由场景的下方向上看浅蓝色的面,移动至场景上方后可以向下看到草绿色的面: