因为OpenGL中的坐标转换有些复杂,所以做一篇笔记记录一下。
一、简介
学习OpenGL一段时间之后,数据的坐标转换将会成为一个令人头疼的问题,因为我们总不能一直只使用窗口的规范化设备坐标系(NDC)来显示数据,这样会对我们产生很大的约束,而如果我们要把我们真实世界中的东西在OpenGL中显示出来,就必须学会使用坐标转换。
在真正进行坐标转换之前,我们首先要了解OpenGL中到底有什么坐标空间,总的来说,其共有五种坐标空间:局部空间(或者称作模型空间)、世界空间、观察空间(或者称作人眼空间)、裁剪空间以及屏幕空间。
1、我们所有的顶点数据刚一开始都位于局部空间之中,这个空间有点类似于我们生活的真实空间,空间内的模型可能很大,几米甚至几十米等。那么这么大的东西如果直接一比一的放入电脑之中,那电脑屏幕什么也不会显示出来。另一方面则是每一个对象都会拥有一套属于自己的局部坐标系,局部空间内对象的原点可能是(0,0,0),也有可能不是,这不利于我们对这些对象进行定位;再者就是局部空间可能很小(根据Learning OpenGL中的内容进行的一些猜测)。总的来说,局部空间就像是专门为了承载单个对象模型而创建的小天地,而每个对象模型的小天地都是独立存在的,没有任何关系,所以我们需要将他们放到一个统一的坐标系(世界坐标系)下,这样我们才能更好的去描述他们之间的相对关系,所以在这个过程中我们可能就需要对我们的模型进行平移、旋转和缩放等操作。
2、这个世界空间为了方便理解,我们可以将其想象成一种游戏空间,玩过游戏的人都清楚游戏内的对战场景会把多个人物模型给放在一起,也就是放到同一个空间之中进行对战,而这个对战空间其实就有点类似于我们所说的世界空间,它是一个更为庞大的空间。
3、之后我们就是要模拟人类看东西的过程,将物体置入观察空间,也就是让我们可以看到这个物体。这个过程有点类似于将一个照相机移到了模型前方的某个位置,然后再设置一下照相机的朝向,让这个照相机可以看到我们所置入的模型。
4、在观察空间中,类比于现实中的人类,我们视域总是有限的,也就是我们能看到的东西总是有限的,所以在OpenGL中其也会让我们去设置一个视域范围,如果模型存在一部分超出了我们设置的视域范围,屏幕将不会显示该部分的模型。
5、当前面都设置好了之后只需要在设置一下视口(一般与窗口大小相同),就可以将模型在屏幕上显示出来。
详细的内容可以参看:https://learnopengl.com/Getting-started/Coordinate-Systems
二、代码实现
2.1简单的测试
Shader.h
#ifndef SHADER_H
#define SHADER_H
#include<glad\glad.h>
#include<string>
#include<fstream>
#include<sstream> //字符流
#include<iostream>
class Shader
{
public:
//着色器程序的ID
unsigned int ID;
//构造函数
Shader(const char* vertexPath, const char* fragmentPath);
//激活着色器
void use();
//设置着色器中的转换模型
void setMat4(const std::string& name ,const glm::mat4 &mat) const;
};
Shader::Shader(const char* vertexPath, const char* fragmentPath) {
//检索顶点和片元着色器源代码的路径
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
//如果发生以下错误程序将会抛出异常,使用下面的语句可以保证异常的抛出
vShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit); //其实就是重置了输入输出和文件读取写入的状态标记
fShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
try
{
//打开文件
vShaderFile.open(vertexPath);
fShaderFile.open(fragmentPath);
std::stringstream vShaderStream, fShaderStream;
//文件的缓冲区内容读入sstream对象中
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
//关闭文件
vShaderFile.close();
fShaderFile.close();
//将字符流转换为string字符串
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch (const std::exception&)
{
std::cout << "文件读取失败!" << std::endl;
}
const char* vShaderCode = vertexCode.c_str(); //将字符串中的字符数组赋给一个字符指针
const char* fShaderCode = fragmentCode.c_str();
//
//编译着色器
//
unsigned int vertex, fragment; //记录着色器对象的索引
int success; //记录着色器编译成功与否
char infolog[512];
//顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER); //创建一个空的着色器对象,把源代码填进去就可以制作顶点着色器程序了
glShaderSource(vertex, 1, &vShaderCode, NULL); //将Shader中的源代码设置为指定数组中的字符串,之前Shader对象中的原代码将会被替代
glCompileShader(vertex); //编译顶点着色器
//检查着色器编译的情况,如果有错误打印出来
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infolog);
std::cout << "顶点着色器编译错误信息:" << infolog << std::endl;
}
//片元着色器
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fShaderCode, NULL);
glCompileShader(fragment);
glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(fragment, 512, NULL, infolog);
std::cout << "片元着色器编译错误信息:" << infolog << std::endl;
}
//编译为着色器程序,将着色器对象添加到一个程序对象上去,其实也就是链接两个着色器对象
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(ID, 512, NULL, infolog);
std::cout << "着色器链接错误信息:" << infolog << std::endl;
}
//将创建的着色器对象给删除掉
glDeleteShader(vertex);
glDeleteShader(fragment);
}
void Shader::use() {
glUseProgram(ID); //激活着色器程序
}
void Shader::setMat4(const std::string& name, const glm::mat4& mat)const {
glUniformMatrix4fv(glGetUniformLocation(this->ID, name.c_str()), 1, GL_FALSE, &mat[0][0]); //设置指定转换的模型的值
}
#endif // !SHADER_H
CT.h
#ifndef CT_H
#define CT_H
//一般的C++头文件
#include<iostream>
#include<vector>
//GLAD
#include<glad\glad.h> //用于初始化我们所需的OpenGL函数
//GLFW
#include<GLFW\glfw3.h> //用于处理窗口相关的事件
//glm
#include<glm\glm.hpp>
#include<glm\gtc\matrix_transform.hpp>
#include<glm\gtc\type_ptr.hpp>
//自定义的类头文件
#include"Shader.h"
//相关的函数原型
//void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode);
namespace CT {
//窗口相关信息
const GLuint WIDTH = 800, HEIGHT = 600;
GLfloat lastX = 400, lastY = 300;
void CT_test() {
//初始化GLFW函数库,操作系统会分配相应的资源
glfwInit();
//设置我们所需要的一些选项,也就是配置GLFW
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); //GLFW所使用的OpenGL版本为3.3版本,如果用户没有3.3版本,则GLFW不能运行
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); //使用OpenGL的核心模式
glfwWindowHint(GLFW_RESIZABLE, GL_FALSE);
//使用GLFW函数创建一个窗口
GLFWwindow* window = glfwCreateWindow(WIDTH, HEIGHT, "CoordinateTransform", nullptr, nullptr);
if (window == NULL)
{
std::cout << "创建窗口失败!" << std::endl;
glfwTerminate(); //终止进程并释放资源
return;
}
glfwMakeContextCurrent(window); //创建当前上下文环境
//初始化GLAD,在我们调用OpenGL函数之前,然后我们就可以直接使用函数了,这类似于将显式链接变为了隐式链接,但是其查找过程并没有改变
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
std::cout << "初始化GLAD失败\n";
return;
}
//设置回调函数让Windows进行调用
//glfwSetKeyCallback(window, key_callback);
//设置视口,也就是我们可见的区域
glViewport(0, 0, WIDTH, HEIGHT);
//创建与编译着色器程序
Shader myShader("../Shader/shader_ct.vs", "../Shader/shader_ct.fs");
//定义顶点数据
//std::vector<GLfloat> vertice; //OpenGL中的函数会接收数组型的数据
GLfloat vertice[] = {
0.0,100,0.0,
-100,0.0,0.0,
100,0.0,0.0
};
//
//显示部分
//
GLuint VBO, VAO;
glGenVertexArrays(1, &VAO); //创建顶点数组对象的名字,其更像是一组指针,负责管理缓冲对象
glGenBuffers(1, &VBO); //创建缓冲区对象名字,分配缓存,负责保存一系列顶点的数据
glBindVertexArray(VAO); //绑定一个顶点数组对象,仅仅只有对象还是没有用处的,我们还需要将他们与当前的OpenGL环境进行绑定才能够被OpenGL使用,也就是激活它
glBindBuffer(GL_ARRAY_BUFFER, VBO); //绑定一个命名的缓冲区对象
glBufferData(GL_ARRAY_BUFFER, sizeof(vertice), &vertice[0], GL_STATIC_DRAW); //创建和初始化一个缓冲区对象的数据存储
//位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GL_FLOAT), (GLvoid*)0); //指定顶点位置数据
glEnableVertexAttribArray(0); //启用了布局标志符为0的变量
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0); // Unbind VAO
glEnable(GL_DEPTH_TEST); //启用深度测试
glPointSize(10);
while (!glfwWindowShouldClose(window))
{
glfwPollEvents(); //监视所有的窗口事件
glClearColor(0.2f, 0.3f, 0.3f, 1.0f); //用什么样的颜色来清除之前的缓冲区
//glClear(GL_COLOR_BUFFER_BIT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
myShader.use();
glBindVertexArray(VAO);
//
//坐标转换
//
//模型变换,将模型置入世界空间之中
glm::mat4 model(1);
model = glm::translate(model, glm::vec3(0, 0, 0)); //将模型平移到点(0,0,0)处
myShader.setMat4("model", model);
//视图变换
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 250.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
myShader.setMat4("view", view);
//投影变换
glm::mat4 prj;
prj = glm::perspective(glm::radians(45.0f), (GLfloat)WIDTH / (GLfloat)HEIGHT, 0.1f, 500.0f);
myShader.setMat4("prj", prj);
//glDrawArrays(GL_POINTS, 0, sizeof(vertice)/(3*sizeof(vertice[0]))); //绘制点
glDrawArrays(GL_TRIANGLES, 0, sizeof(vertice) / (3 * sizeof(vertice[0])));
glfwSwapBuffers(window); //交换缓冲区
}
//释放掉相应的资源
glDeleteVertexArrays(1, &VAO);
glDeleteBuffers(1, &VBO);
//清理窗口
glfwDestroyWindow(window);
glfwTerminate(); //清除掉分配给glfw的资源
}
}
#endif // !CT_H
main.cpp
#include"CT.h"
int main() {
CT::CT_test();
return 0;
}
着色器代码:
shader_ct.vs
#version 330 core
//进行布局
layout (location=0) in vec3 aPos; //接收传进来的数据
out vec4 myColor;
//定义坐标转换变量
uniform mat4 model;
uniform mat4 view;
uniform mat4 prj;
void main(){
gl_Position = prj * view * model * vec4(aPos, 1.0); //对顶点数据进行坐标转换
myColor = vec4(0,1,0,1);
}
shader_ct.fs
#version 330 core
in vec4 myColor; //接收传过来的数据
out vec4 fragColor;
void main(){
fragColor = myColor;
}
测试效果:
2.2旋转测试
在model模型变换矩阵的代码中添加下面的代码:
//模型变换,将模型置入世界空间之中
glm::mat4 model(1);
model = glm::rotate(model, (float)glfwGetTime(), glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::translate(model, glm::vec3(0, 0, 0)); //将模型平移到点(0,0,0)处
myShader.setMat4("model", model);
测试效果:
三、小结
为什么要搞这么多坐标空间呢?这是我在学习这部分的时候最大的疑问,《learning OpenGL》一书中对这个问题倒是有很好的解答。每一个空间其实都是为了后续的一些特定的操作而服务的,如对象自身的一些修改,那自然是在局部空间中进行这些修改操作是较好的;而如果要使用对象与对象之间的相对关系,则是在世界空间中更为合适,这类的操作可能还有很多。OpenGL的设计者肯定也是考虑过这些问题的,前面的繁琐是为了后面的便捷,而我现阶段要做的就是学会怎么灵活的使用它就很不错了~。
参考资料:《Learning OpenGL》《OpenGL编程指南》