通过前面的学习,已经了解了OpenGL渲染的主要流程和基础的数学知识,接下来继续学习如何管理3D图形数据,在本回中将会绘制一个立方体。
一、缓冲区和顶点属性
要想绘制一个对象,它的顶点数据需要被发送给顶点着色器。在C++/OpenGl程序中,通常会把顶点数据在C++端放一个缓冲区中,并把这个缓冲区和着色器中声明的顶点属性相关联。
所有的缓冲区通常在程序开始的时候统一创建。在OpenGL中,缓冲区被包含在顶点缓冲对象(Vertex Buffer Object, VBO)中,VBO在C++/OpenGL应用程序中被声明和实例化。一个场景可能需要很多个VBO,通常我们在初始化的阶段生成并填充若干个VBO,方便后续直接使用。
缓冲区使用特定的方式和顶点属性交互。当glDrawArrays()被执行时,缓冲区中的数据开始流动,从缓冲区的开头开始,按顺序流过顶点着色器,顶点着色器对每个顶点执行一次。3D空间中 的顶点需要3个数值,所有着色器中的顶点属性常常会以vec3类型接收到这3个值,然后,对缓冲区中的每组这3个值,着色器会被调用。
在OpenGL中还有一种相关的结构,叫做顶点数组对(Vertex Array Object, VAO),OpenGL3.0版本中引入了VAO,作为一种组织缓冲区的方法,让缓冲区在复杂场景中更容易操作,OpenGL要求至少创建一个VAO。
举个栗子,假设我们要显示两个对象。在C++端,我们可以声明一个VAO和两个VBO,
GLuint vao[1];//OpenGL要求这些数值以数组的形式指定 GLuint vbo[2]; ....... glGenVertexArrays(1,vao); glBindVertexArray(vao[0]); glGenBuffers(2,vbo);
glGenVertexArrays()和glGenBuffers()这两个OpenGL命令分别创建VAO和VBO,并返回它们的整数ID,把这些ID存入整形数组vao和vbo中。这两个命令的参数分别为要创建ID的数目和用来保存返回ID的数组。glBindVertexArray()命令的目的是将指定的VAO标记为活跃,这样生成的缓冲区就会和这个VAO相关连。
每个缓冲区需要有在顶点着色器中声明的相应的顶点属性变量。顶点属性通常是顶点着色器中首先被声明的变量。如:
layout (location = 0) in vec3 position
命令中的“layout(location = 0)"这部分叫做"layout修饰符“,也就是我们把顶点属性和特定缓冲区关联起来的方法,并且这个顶点属性的识别号为0。关键字"in"的意思是输入(input),表示这个顶点属性将会从缓冲区中接收数值,”vec3"的意思是着色器每次调用会抓到3个浮点类型的值(分别表示X、Y、Z,它们组成一个顶点数据),变量的名字是position。
把一个模型的顶点加载到缓冲区(VBO)的方法取决于模型的顶点数据存储在哪里,现在,假设我们想要绘制一个立方体,并且假定我们的立方体的顶点数据在C++/OpenGl应用程序中的数组中直接指定。在这种情况下,步骤如下:
假设顶点数据存储在名为vPosition的浮点类型数组中,一下代码会将这些值复制到第零个缓冲区中。
glBindBuffer(GL_ARRAY_BUFFER,vbo[0]); glBufferData(GL_ARRAY_BUFFER,sizeof(vPosition),vPosition,GL_STATIC_DRAW);
接下来,我们将缓冲区中的值发送到着色器中的顶点属性。通过以下3个步骤来实现:
启用顶点属性。以下代码段实现这些步骤。
glBindBuffer(GL_ARRAY_BUFFER,vbo[0]);//第0个缓冲区被标记为活跃。 glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,0,0);//将第0个属性关联到缓冲区 glEnableVertexAttribArray(0);//启用第0个顶点属性
现在,当执行glDrawArrays()时,第0个VBO中的数据将被传送给拥有位置0的layout修饰符的顶点属性中。总结一下,绘制一个对象,其顶点数据需要被发送给顶点着色器并绘制的步骤如下:
二、统一变量
要想渲染一个3D场景,需要构建合适的变换矩阵(如上回所述),并将变换矩阵应用于模型的每个顶点,在顶点着色器中应用所需的矩阵运算是最有效的,并且习惯上会将这些矩阵发送给着色器中的同一变量。
使用“uniform"关键字在着色器中声明统一变量。如下:
uniform mat4 mv_matrix; uniform mat4 proj_matrix;
关键字mat4表示这些4×4矩阵。将保存模型-视图的矩阵变量命名为mv_matrix,将用来保存投影矩阵的变量命名为proj_matrix。
将数据发送到统一变量需要以下步骤:
在我们的立方体例子中,假设链接的渲染程序保存在名为”renderingProgram"的变量中,并已经利用GLM工具构建了模型-视图矩阵mvMat和投影矩阵pMat,以下代码表示将模型—视图矩阵和投影矩阵发送到两个统一变量mv_matrix和proj_matrix中。
mvLoc = glGetUniformLocation(renderingProgram,"mv_matrix");//获取着色器程序中统一变量的位置 projLoc = glGetUniformLocation(renderingProgram,"proj_matrix"); glUniformMatrix4fv(mvLoc, 1, GL_FALSE,glm::value_ptr(mvMat));//将矩阵数据发送到统一变量中 //glm::value_ptr()返回对矩阵数据的引用 glUniformMatrix4fv(projLoc,1,GL_FALSE,glm::value_ptr(pMat));
三、顶点属性插值
我们已经知道,在片段着色器光栅化之前,由顶点定义的图元(如三角形)被转化为片段。光栅化过程会线性插值顶点属性值,以便显示的像素能无缝连接建模的曲面。相比之下,统一变量的行为类似于初始化过程的常量,并且在每次顶点着色器调用中保持不变。统一变量本身不是插值的,无论有多少顶点,它始终包含相同的值。
在顶点着色器中看到的顶点属性被声明为“in”,表示它们从缓冲区接收值,顶点属性还可以声明为“out"这意味着它们会将值发送给管线的下一阶段。没有必要为顶点位置声明一个“out"变量,在OpenGL中有一个内置的vec4变量用于此目的——gl_Position。在顶点着色器中,我们将矩阵变换应用于传入的顶点,并将结果返回给gl_Position:
gl_Position = proj_matrix * mv_matrix * position;
然后,变换后的顶点将自动输出到光栅着色器,最终将相应的像素发送给片段着色器。在glDrawArrays()函数中指定GL_TRIANGLES时,光栅化是逐个三角形完成的。首先沿着连接顶点的线开始插值,然后通过沿着连接边缘像素水平线插来填充三角形。
四、第一个3D程序——3D立方体
渲染立方体需要用到矩阵的变换,需要自己引入GLM库,方法比较简单,直接到官网上下载GLM库,解压后将内部的glm文件夹直接放到QT工程文件下即可。
mywidget.h
#ifndef MYWIDGET_H #define MYWIDGET_H #define numVAOs 1 #define numVBOs 2 #include<GL/glew.h> //该头文件一定要在最前面 #include<QOpenGLWidget> #include<QOpenGLFunctions> #include<qopengl.h> #include<QOpenGLBuffer> #include<QOpenGLShader> #include<QOpenGLShaderProgram> #include<GL/glfw3.h> #include<GL/gl.h> #include<GL/glu.h> #include<glm/glm.hpp> #include<glm/gtc/type_ptr.hpp> #include<glm/gtc/matrix_transform.hpp> #include<iostream> #include<cmath> #include<string> #include<fstream> #include<QTimer> using namespace std; //继承OpenGLWidget,重写initializeGL(),paintGL(),resizeGL()三个函数即可绘制OpenGL图元 class MyWidget : public QOpenGLWidget,protected QOpenGLFunctions { public: MyWidget(QWidget *parent); GLuint createShaderProgram();//GLuint相当于C++里的unsinged int类型 void setupVertices(void); public slots://定义槽函数 void animate(); protected: void initializeGL() override; void paintGL() override; void resizeGL(int width, int height) override; private: float cameraX,cameraY,cameraZ; float cubeLocX,cubeLocY,cubeLocZ; GLuint renderingProgram; GLuint vao[numVAOs]; GLuint vbo[numVBOs]; GLuint mvLoc,projLoc; int width,height; float aspect; glm::mat4 pMat,vMat,mMat,mvMat,tMat,rMat; float x = 0.0f; float inc = 0.02f; }; #endif // MYWIDGET_H
mywidget.cpp
#include "mywidget.h" MyWidget::MyWidget(QWidget *parent) { Q_UNUSED(parent); QSurfaceFormat format = QSurfaceFormat::defaultFormat(); format.setProfile(QSurfaceFormat::CoreProfile); format.setVersion(3, 3); QSurfaceFormat::setDefaultFormat(format); } void MyWidget::initializeGL() { initializeOpenGLFunctions(); glClearColor(0.0,0.0,0.0,1.0);//设置背景色为黑色 glClear(GL_COLOR_BUFFER_BIT); renderingProgram = createShaderProgram(); cameraX = 0.0f;//初始化相机位置 cameraY = 0.0f; cameraZ = 8.0f; cubeLocX = 0.0f;//初始化立方体的位置 cubeLocY = -2.0f; cubeLocZ = 0.0f; setupVertices(); } void MyWidget::paintGL() { //每帧都清除深度缓冲区并填充背景颜色为黑色 glClearColor(0.0,0.0,0.0,1.0); glClear(GL_COLOR_BUFFER_BIT); //启用着色器,在GPU上安装GLSL代码 glUseProgram(renderingProgram); //绘制MV矩阵和投影矩阵的统一变量 mvLoc = glGetUniformLocation(renderingProgram,"mv_matrix"); projLoc = glGetUniformLocation(renderingProgram,"proj_matrix"); //构建透视矩阵 //glfwGetFramebufferSize((GLFWwindow*)this,&width,&height); width = 1000;height = 800; aspect = (float)width / (float)height; pMat = glm::perspective(1.0472f,aspect,0.1f,1000.0f);//1.0472rad = 60度 //构建视图矩阵 vMat = glm::translate(glm::mat4(1.0f),glm::vec3(-cameraX,-cameraY,-cameraZ)); //静态立方体 //mMat = glm::translate(glm::mat4(1.0f),glm::vec3(cubeLocX,cubeLocY,cubeLocZ)); //动态立方体,利用平移和旋转矩阵实现动画 x += inc; tMat = glm::translate(glm::mat4(1.0f),glm::vec3(sin(0.35*x)*2.0f,cos(0.52*x)*2.0f,sin(0.7*x)*2.0f)); rMat = glm::rotate(glm::mat4(1.0f),1.75f*x,glm::vec3(0.0f,1.0f,0.0f)); rMat = glm::rotate(rMat,1.75f*x,glm::vec3(1.0f,0.0f,0.0f)); rMat = glm::rotate(rMat,1.75f*x,glm::vec3(0.0f,0.0f,1.0f)); mMat = tMat * rMat; mvMat = vMat * mMat; //将透视矩阵和MV矩阵复制给相应的统一变量 glUniformMatrix4fv(mvLoc,1,GL_FALSE,glm::value_ptr(mvMat)); glUniformMatrix4fv(projLoc,1,GL_FALSE,glm::value_ptr(pMat)); //将VBO关联给顶点着色器中的相应的顶点属性 glBindBuffer(GL_ARRAY_BUFFER,vbo[0]); glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,0,0); glEnableVertexAttribArray(0); //调整OpenGL设置,绘制模型 glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); glDrawArrays(GL_TRIANGLES,0,36); } void MyWidget::resizeGL(int width, int height) { } GLuint MyWidget::createShaderProgram() { //顶点着色器 const char *vshaderSource = "#version 430 \n" "layout (location = 0) in vec3 position; \n" "uniform mat4 mv_matrix; \n" "uniform mat4 proj_matrix; \n" "out vec4 varyingColor; \n" "void main(void) \n" "{ gl_Position = proj_matrix * mv_matrix *vec4(position,1.0); \n" "varyingColor = vec4(position,1.0)*0.5 + vec4(0.5,0.5,0.5,0.5);}"; //顶点沿着管线移动到光栅着色器,它们会在这里被转化为像素(片段)位置,最终这些像素(片段)到达片段着色器 //片段着色器的目的就是将要展示的像素赋予RGB颜色 //"out"标签表明color变量是输出变量 //此处vec4前三个元表示RGB颜色,第四个元表示不透明度 const char *fshaderSource = "#version 430 \n" "in vec4 varyingColor; \n" "out vec4 color; \n" "uniform mat4 mv_matrix; \n" "uniform mat4 proj_matrix; \n" "void main(void) \n" "{ color = varyingColor;}"; //调用glCreateShader(parameter)创建类型为parameter的空着色器 //创建每个着色器对象后会返回一个整数ID作为后面引用它们的序号 GLuint vShader = glCreateShader(GL_VERTEX_SHADER);//GLuint相当于“unsigned int" GLuint fShader = glCreateShader(GL_FRAGMENT_SHADER); //glShaderSource将GLSL代码从字符串载入空着色器对象中 //四个参数:1、存放着色器的着色器对象,2、着色器源代码中的字符串数量,3、包含源代码的字符串指针,4、 glShaderSource(vShader,1,&vshaderSource,NULL); glShaderSource(fShader,1,&fshaderSource,NULL); //glCompileShader编译着色器 glCompileShader(vShader); glCompileShader(fShader); //创建程序对象,并存储指向它的整数ID //OpenGL的程序对象包含一系列编译过的着色器,使用glAttachShader将着色器加入程序对象 //之后使用glLinkProgram来请求GLSL编译器来确保它们的兼容性。 GLuint vfProgram = glCreateProgram(); glAttachShader(vfProgram,vShader); glAttachShader(vfProgram,fShader); glLinkProgram(vfProgram); return vfProgram; } void MyWidget::setupVertices() { float vertexPositions[108] = {//该数组包含了立方体的36个顶点,利用三角形构建的立方体,立方体每个面由2个三角形组成 -1.0f, 1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f,1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, -1.0f, 1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, 1.0f, -1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 1.0f, -1.0f, 1.0f, -1.0f, }; //创建VAO并设置为活跃状态,不用这两行程序也可运行 glGenVertexArrays(1,vao); glBindVertexArray(vao[0]); //创建VBO并设置为活跃状态 glGenBuffers(numVBOs,vbo); glBindBuffer(GL_ARRAY_BUFFER,vbo[0]); //将顶点数据复制到缓存中 glBufferData(GL_ARRAY_BUFFER,sizeof (vertexPositions),vertexPositions,GL_STATIC_DRAW); } void MyWidget::animate() { this->update();//调用update时会自动调用PaintGL函数重绘 }
mainwindow.h
#ifndef MAINWINDOW_H #define MAINWINDOW_H #include <QMainWindow> #include"mywidget.h" QT_BEGIN_NAMESPACE namespace Ui { class MainWindow; } QT_END_NAMESPACE class MainWindow : public QMainWindow { Q_OBJECT public: MainWindow(QWidget *parent = nullptr); ~MainWindow(); private: Ui::MainWindow *ui; MyWidget *my_widget; }; #endif // MAINWINDOW_H
mainwindow.cpp
#include "mainwindow.h" #include "ui_mainwindow.h" #include<QTimer> MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); //创建OpenGLWidget对象 my_widget = new MyWidget(this); //将mainWindow界面设置为OpenGLWidget setCentralWidget( my_widget ); //设置界面的大小 resize( 1000, 800 ); //设置计时器并连接槽函数 QTimer *timer = new QTimer(this); connect(timer, &QTimer::timeout, my_widget, &MyWidget::animate); timer->start(10);//设置重绘的时间间隔 } MainWindow::~MainWindow() { delete ui; }
main.cpp
#include "mainwindow.h" #include <QApplication> int main(int argc, char *argv[]) { QApplication a(argc, argv); MainWindow w; w.show(); return a.exec(); }
运行结果