OpenGL学习笔记(5)----坐标系统、摄像机

时间:2024-10-14 07:36:24

OpenGL学习笔记(5)----坐标系统、摄像机

  • 引言
  • 坐标系统
    • 理论
      • 局部空间
      • 世界空间
      • 观察空间
      • 裁剪空间
        • 投影矩阵
        • 投影方式
        • 透视除法
      • 屏幕空间
    • 代码实现
      • model矩阵
      • view矩阵
      • projection矩阵
      • 顶点着色器代码
  • 摄像机
    • 理论
      • LookAt矩阵
    • 代码实现
      • 鼠标输入
      • 键盘输入
      • 摄像机类

引言

上一次笔记学习了OpenGL中的着色器的基本操作,纹理的使用,以及变换矩阵,我已经可以绘制出一个有纹理,会移动的2D三角形了。经过这一次的学习,我能够通过实现坐标系统将一个3D空间中的3D立方体绘制到屏幕空间中,还可以自定义摄像机类,在3D空间中*移动。

坐标系统

先讲一些理论的东西。

理论

将坐标变换为标准化设备坐标,接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。目前比较重要的坐标系统有:

  1. 局部空间(Local Space,或者称为物体空间(Object Space))
  2. 世界空间(World Space)
  3. 观察空间(View Space,或者称为视觉空间(Eye Space))
  4. 裁剪空间(Clip Space)
  5. 屏幕空间(Screen Space)

在这些空间之间转换的过程中中有3个重要的矩阵:模型(model),观察(view),投影(projection)矩阵。
在这里插入图片描述
这一系列的空间中的坐标初始为vec4,即三个位置坐标和一个齐次坐标。

局部空间

局部空间中的坐标为局部坐标,位置坐标是模型的导入时的坐标,齐次坐标一般设为1.0。局部空间的原点由建模人员在建模时决定的,一般在物体中心。

世界空间

世界空间就是整个3D世界的空间,该空间中的坐标称为世界坐标
模型坐标需要变换操作(缩放,旋转,平移)变为世界坐标,这个变换操作通过模型矩阵实现,模型矩阵是模型相关的。

观察空间

观察空间也叫摄像机空间(Camera Space)或者视觉空间(Eye Space),该阶段坐标称为观察坐标
从世界空间到观察空间的过程是把物体相对于世界原点的坐标转化为相对于摄像机的坐标,具体做法就是把摄像机相对于世界原点的位移、旋转操作反向加到所有物体的世界坐标上,这个操作就是观察矩阵做的事。观察矩阵是摄像机相关的。

裁剪空间

在一个顶点着色器运行的最后,OpenGL期望所有的坐标都能落在一个特定的范围内,且任何在这个范围之外的点都应该被裁剪掉(Clipped)。被裁剪掉的坐标就会被忽略,所以剩下的坐标就将变为屏幕上可见的片段。这也就是裁剪空间(Clip Space)名字的由来。
从观察空间到裁剪空间就是完成下面两个空间的映射(透视投影时)
在这里插入图片描述
该映射需要两个过程,先将观察坐标通过投影矩阵转化成裁剪坐标,再通过透视除法将剪裁坐标变成标准化设备坐标(normalized device coordinates NDC)。如下图所示
在这里插入图片描述在这里插入图片描述
裁剪坐标是为了方便计算定义出来的坐标。

投影矩阵

从观察坐标转到裁剪坐标需要投影矩阵来操作,投影矩阵有两个主要的参数,投影方式和摄像机的观察箱(一个平截头体Frustum)。投影矩阵是摄像机相关的。

投影方式

投影方式有两种:

  1. 正射投影
    在这里插入图片描述
    正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,齐次坐标不改变。
  2. 透视投影
    在这里插入图片描述
    透视投影将给定的平截头体范围映射到裁剪空间,除此之外还修改了每个顶点坐标的w值(齐次坐标),从而使得离观察者越远的顶点坐标w分量越大。(投影矩阵的具体数学方法
透视除法

当透视投影时,矩阵乘出的坐标是裁剪坐标,OpenGL会自动进行一个叫做透视除法的操作,将裁剪坐标变成标准化设备坐标:
o u t = ( x / w y / w z / w ) out =

(x/wy/wz/w)
out=x/wy/wz/w
这个操作会使更远处坐标的绝对值变小,形成下面的效果:
在这里插入图片描述
当正射投影时,矩阵乘出的坐标直接是标准化设备坐标,不进行透视除法。

屏幕空间

将标准化设备坐标变换为屏幕坐标,OpenGL会使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的裁剪坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

代码实现

在上面几个空间的转换中,我们需要向顶点着色器提供三个矩阵model,view,projection。其中model矩阵是模型相关,一个模型对应一个model矩阵;view,projection矩阵是摄像机相关。
坐标转换的公式:
V c l i p = M p r o j e c t i o n ⋅ M v i e w ⋅ M m o d e l ⋅ V l o c a l V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local} Vclip=MprojectionMviewMmodelVlocal
在得到Vclip后,OpenGL会自动对顶点坐标进行透视除法和视口转换。

model矩阵

该矩阵可以用上一节的知识自己定义模型的位置、大小、旋转。

glm::mat4 model = glm::mat4(1.0f);
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));
  • 1
  • 2

view矩阵

view矩阵就是摄像机位置的反向操作

glm::mat4 view;
// 注意,我们将矩阵向我们要进行移动场景的反方向移动。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
  • 1
  • 2
  • 3

projection矩阵

正射投影

glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
  • 1

透视投影

glm::perspective(glm::radians(45.0f), (float)window_width/(float)window_height, 0.1f, 100.0f);
  • 1

第一个参数是视野值fov(Field of View)
在这里插入图片描述

顶点着色器代码

#version 330 core
layout (location = 0) in vec3 aPos;
...
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
    // 注意乘法要从右向左读
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

摄像机

理论

3D世界中摄像机摄像机的实现思想是通过捕捉用户的键盘和鼠标的输入来改变摄像机的观察位置和摄像机的观察方向。
上面提到我需要提供3个矩阵:model,veiw,projection来完成3d世界的绘制。其中view矩阵和projection矩阵与摄像机相关。view与摄像机的姿态相关:位置,旋转。projection与摄像机的“镜头”相关:视界。
所以我们要做的就是把用户的输入映射到摄像机变化上。
键盘wasd 改变 摄像机位置
鼠标滑动 改变 摄像机姿态
鼠标滚轮 改变 摄像机的视界(放大缩小)

LookAt矩阵

LookAt矩阵可以乘以任何向量来将其变换到某个坐标空间,该坐标空间由R(右向量),U(上向量),D(方向向量),三个正交向量构成的右手坐标系和一个位置向量P定义:
L o o k A t = [ R x R y R z 0 U x U y U z 0 D x D y D z 0 0 0 0 1 ] ∗ [ 1 0 0 − P x 0 1 0 − P y 0 0 1 − P z 0 0 0 1 ] LookAt =

[Rxamp;Ryamp;Rzamp;0Uxamp;Uyamp;Uzamp;0Dxamp;Dyamp;Dzamp;00amp;0amp;0amp;1]
*
[1amp;0amp;0amp;Px0amp;1amp;0amp;Py0amp;0amp;1amp;Pz0amp;0amp;0amp;1]
LookAt=RxUxDx0RyUyDy0RzUzDz00001100001000010PxPyPz1
LookAt做的事其实就是view矩阵要实现的事,所以我们只需要实时的监控摄像机的位置向量P和三个资态向量R、U、D,生成摄像机坐标系的LookAt矩阵,作为view矩阵传给定点着色器。

代码实现

总结一下一个摄像机类要做的事情:读取用户在键盘WASD、鼠标滑动、鼠标滑轮的输入,输出LookAt矩阵和fov值。

鼠标输入

如何将鼠标的操作送到摄像机类中。
首先要隐藏光标并捕捉(Capture)光标,捕捉光标的意思是,如果焦点在你的程序上,光标应该停留在窗口中(除非程序失去焦点或者退出)。

glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
  • 1

然后监听鼠标的移动事件

void mouse_callback(GLFWwindow* window, double xpos, double ypos);
glfwSetCursorPosCallback(window, mouse_callback);
  • 1
  • 2

处理移动事件

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; // y的坐标是上面小,下面大
    lastX = xpos;
    lastY = ypos;
    
    camera.ProcessMouseMovement(xoffset, yoffset);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

滚轮输入类似

......
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
......
int main(){
	......
	glfwSetScrollCallback(window, scroll_callback);
	......
}
......
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset){
	// yoffset代表滚轮滚动的值
    camera.ProcessMouseScroll(yoffset);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

键盘输入

在每一帧渲染的过程中进行事件捕捉:

void processInput(GLFWwindow *window){
	.......
    // 获得渲染时间,保证速度相同
    float currentFrame = glfwGetTime();
    deltaTime = currentFrame - lastFrame;
    lastFrame = currentFrame;
    // 键盘移动
    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);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

摄像机类

下面是摄像机类的属性和方法

#ifndef CAMERA_H
#define CAMERA_H

#include <glad/>
#include <glm/>
#include <glm/gtc/matrix_transform.hpp>

#include <vector>

// Defines several possible options for camera movement. Used as abstraction to stay away from window-system specific input methods
enum Camera_Movement {
    FORWARD,
    BACKWARD,
    LEFT,
    RIGHT
};

// Default camera values
const float YAW         = -90.0f;
const float PITCH       =  0.0f;
const float SPEED       =  2.5f;
const float SENSITIVITY =  0.1f;
const float ZOOM        =  45.0f;


// An abstract camera class that processes input and calculates the corresponding Euler Angles, Vectors and Matrices for use in OpenGL
class Camera{
public:
    // Camera Attributes
    glm::vec3 Position;
    glm::vec3 Front;
    glm::vec3 Up;
    glm::vec3 Right;
    glm::vec3 WorldUp;
    // Euler Angles
    float Yaw;
    float Pitch;
    // Camera options
    float MovementSpeed;
    float MouseSensitivity;
    float Zoom;

    // Constructor with vectors
    Camera(glm::vec3 position = glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f), float yaw = YAW, float pitch = PITCH) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM){
        Position = position;
        WorldUp = up;
        Yaw = yaw;
        Pitch = pitch;
        updateCameraVectors();
    }
    // Constructor with scalar values
    Camera(float posX, float posY, float posZ, float upX, float upY, float upZ, float yaw, float pitch) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM){
        Position = glm::vec3(posX, posY, posZ);
        WorldUp = glm::vec3(upX, upY, upZ);
        Yaw = yaw;
        Pitch = pitch;
        updateCameraVectors();
    }

    // Returns the view matrix calculated using Euler Angles and the LookAt Matrix
    glm::mat4 GetViewMatrix();

    // Processes input received from any keyboard-like input system. Accepts input parameter in the form of camera defined ENUM (to abstract it from windowing systems)
    void ProcessKeyboard(Camera_Movement direction, float deltaTime);

    // Processes input received from a mouse input system. Expects the offset value in both the x and y direction.
    void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true);

    // Processes input received from a mouse scroll-wheel event. Only requires input on the vertical wheel-axis
    void ProcessMouseScroll(float yoffset);

private:
    // Calculates the front vector from the Camera's (updated) Euler Angles
    void updateCameraVectors();
};
#endif
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76

摄像机首先要做的是接受输入,维护自己的姿态状态:

    void ProcessKeyboard(Camera_Movement 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;
    }

    void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true){
        xoffset *= MouseSensitivity;
        yoffset *= MouseSensitivity;

        Yaw   += xoffset;
        Pitch += yoffset;

        // Make sure that when pitch is out of bounds, screen doesn't get flipped
        if (constrainPitch){
            if (Pitch > 89.0f)
                Pitch = 89.0f;
            if (Pitch < -89.0f)
                Pitch = -89.0f;
        }

        // Update Front, Right and Up Vectors using the updated Euler angles
        updateCameraVectors();
    }

    void ProcessMouseScroll(float yoffset){
        if (Zoom >= 1.0f && Zoom <= 45.0f)
            Zoom -= yoffset;
        if (Zoom <= 1.0f)
            Zoom = 1.0f;
        if (Zoom >= 45.0f)
            Zoom = 45.0f;
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

摄像机主要的旋转姿态是通过维护两个角度俯仰角(Pitch)、偏航角(Yaw),同步更新向量。

    void updateCameraVectors(){
        // Calculate the new Front vector
        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);
        // Also re-calculate the Right and Up vector
        Right = glm::normalize(glm::cross(Front, WorldUp));  // Normalize the vectors, because their length gets closer to 0 the more you look up or down which results in slower movement.
        Up    = glm::normalize(glm::cross(Right, Front));
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

LookAt矩阵的生成可以直接用glm提供的函数

    glm::mat4 GetViewMatrix(){
        return glm::lookAt(Position, Position + Front, Up);
    }
  • 1
  • 2
  • 3

glm::LookAt函数需要一个位置、目标和上向量。
这样Camera类就整好了,在主函数中每次渲染的时候获取实时LookAt矩阵和zoom值,生成相应的view矩阵和projection矩阵就可以实现摄像机的移动了。
可以参考我的代码

本文的思路和出现的图来自于