OpenGL学习记录--入门章节


窗口创建和初始化

  在开启窗口之前,需要先初始化GLFW,用glfwWindowHint()函数去指定使用的OpenGL的版本号,还需要告诉GLFW使用的核心模式。实例化窗口之后用glfwMakeContextCurrent()去把当前窗口设置为当前线程主窗口。再使用processInput()函数在用户拖动窗口大小时同时改变渲染窗口的大小。

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
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}

void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
glfwSetWindowShouldClose(window, true);
}
}

#pragma region Init And Open Window
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);

GLFWwindow* window = glfwCreateWindow(800, 600, "Sword Art Online", NULL, NULL);
if (window == NULL)
{
std::cout << "SAO World Failed" << std::endl;
}
glfwMakeContextCurrent(window);

if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Init GLAD Failed" << std::endl;
return -1;
}

glViewport(0, 0, 800, 600);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
#pragma endregion

渲染循环

  所有的渲染指令全部放到渲染循环之中,使用glClear()来清空屏幕的颜色缓冲,它接受一个缓冲位(Buffer Bit)来指定要清空的缓冲,可能的缓冲位有GL_COLOR_BUFFER_BITGL_DEPTH_BUFFER_BITGL_STENCIL_BUFFER_BIT。调用了glClearColor()来设置清空屏幕所用的颜色。当调用glClear(),清除颜色缓冲之后,整个颜色缓冲都会被填充为glClearColor()里所设置的颜色。

1
2
3
4
5
6
7
8
9
10
11
while (!glfwWindowShouldClose(window))
{
processInput(window);

glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);

glfwSwapBuffers(window);
glfwPollEvents();
}
glfwTerminate();

glfwSwapBuffers()是用于交换颜色缓冲,是因为现在使用渲染窗口绘图时都是采用双缓冲(Double Buffer)的方式,消除了单缓冲带来的不真实感。

glfwPollEvents()用来返回用户的输入。


图形渲染管线

  图形渲染管线其实就是把一大堆3D的数据转化为屏幕上的2D像素,所有的渲染都是通过这个管线将你想让它出现的东西最终出现在屏幕上。图形渲染管线有几个阶段,每个阶段都把前一个阶段的结果(输入)拿来操作,并且这些操作具有并行性,所以大多数显卡都有成千上万的小处理核心(并行性也是GPU速度很快的原因),它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理数据。这些小程序就叫做着色器(Shader),它们运行在GPU上,并且有的着色器可以由我们来编程控制。

为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去指定这些数据所表示的渲染类型。做出的这些提示叫做图元(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTS、GL_TRIANGLES、GL_LINE_STRIP

顶点着色器 Vertex Shader

  所有的顶点数据都会进入Vertex Shader中,它的功能是把输入的数据进行矩阵变换位置,计算光照公式生成逐顶点颜⾊,⽣成/变换纹理坐标。并为所输入的数据确定属性,把它们具体区分为点的空间位置、面的法向量、纹理坐标或者颜色。

几何着色器 Geometry Shader

  通过Shader程序可以指定Geometry Shader对顶点信息进行增减。因为实际增减的是复数的顶点,所以对各种的线段、多边形、粒子等图元也可以进行增减。它可以通过增加顶点让边和面变得更加平滑。

光栅化 Rasterization

  就是通过坐标变换等操作将3D的数据转化成像素在屏幕上显示。

片段着色器 Fragment Shader

  主要目的是计算一个像素的最终颜色,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据都会被用来计算最终像素的颜色,很多高级的效果都在这里实现。


VBO&VAO

  CPU在得到.obj的顶点数据之后,就进入了顶点着色器的阶段,CPU会把这些数据送给GPU,GPU就会创建内存去存储这些数据,而VBO(Vertex Buffer Objects)就是用来存储的Buffer,它会在GPU内存中储存大量顶点。使用这些缓冲对象的好处是可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。

  VAO(Vertex Array Object)顶点数组对象就相当于是一个索引,它用于对这些顶点数据进行解析,分析出里面哪些是描述的空间位置,哪些描述的是法线或者颜色。VAO可以绑定两种Buffer,一种是ArrayBuffer一种是ElementBuffer。

OpenGL的核心模式要求我们必须使用VAO,它知道该如何处理顶点输入。如果绑定VAO失败,OpenGL会拒绝绘制任何东西。

VAO和VBO是一一对应的,一个模型的数据输入(一套完整独立的数据)对应一个VAO

​ 我们可以同时创建多个VBO和EBO,它们都可以与VAO进行绑定:

1
2
3
4
5
6
7
8
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);

unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

ShaderProgram

  OpenGL的着色器是使用GLSL写的,是一种类C语言,在需要编译Shader时可以采用文件读取,也可以硬编码在字符串中。

1
2
3
4
5
6
7
8
9
10
11
12
13
const char* vertexShaderSource =
" #version 330 core \n "
" layout(location = 0) in vec3 aPos;\n "
" void main()\n "
" {\n "
" gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);}\n ";

const char* fragmentShaderSource =
" #version 330 core \n "
" out vec4 FragColor;\n "
" void main()\n "
" {\n "
" FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);}\n ";

  在VertexShader里面,layout (location = 0)设定了输入变量的位置值,它决定了输入数据在VAO中的位置。它与glVertexAttribPointer()函数的第一个参数是对应的,代表着顶点属性的位置,在OpenGL中它能提供的位置为0~15。

  我们使用glCreateShader()来创建Shader,这个函数要指明Shader的类型,然后使用glShaderSource()函数把着色器的源码给加载到这个着色器对象上,这个函数把要编译的着色器对象作为第一个参数,第二参数指定了传递的源码字符串数量,第三个参数是顶点着色器的源码。下面分别创建VertexShader和FragmentShader:

1
2
3
4
5
6
7
8
9
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

接着就是将刚刚创建好的两个shader拿来一起编译,用glCreateProgram()取得创建的Program的编号,然后分别将之前的顶点着色器和片段着色器附加到上面,最后再连接编译:

1
2
3
4
5
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
glAttachShader(shaderProgram,vertexShader);
glAttachShader(shaderProgram,fragmentShader);
glLinkProgram(shaderProgram);

每当你想要绘制一个物体时,你都需要先绑定对应的VAO,然后再调用之前的ShaderProgram。还需要使用glVertexAttribPointer()函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)。这里就是指从第零个开始挖数据,每三个为一组,即每隔三个挖出一组。

1
2
3
4
5
6
//绑定,加载源代码
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
//绘制,在While循环中
glUseProgram(shaderProgram);
glDrawArrays(GL_TRIANGLES, 0, 3);

完成以上代码就可以绘制出OpenGL中最基础的三角形:

可喜可贺!!!

但是在这里我们需要多提一下,如果我们把顶点增加为六个:

1
2
3
4
5
6
7
8
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f,
0.8f, 0.8f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f,
};

这时画出的是一个四边形:

  但是OpenGL默认的三角形的绘制顺序是逆时针,也就是说我们新增的那三个顶点所画出的那个三角形其实是背面,为了验证我们加两行代码,打开OpenGL的背面剔除:

1
2
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);

  这时再次运行就只有最初的那一个三角形在,同理,你变成剔除正面:GL_FRONT,这时左边的三角形就不会被画出来。


EBO

  不需要六个点,只用四个点就可以画出四边形,我们就需要EBO,索引缓冲对象(Element Buffer Object,EBO。它实际上是给出了点的绘制顺序:

1
2
3
4
5
6
float vertices[] = {
-0.5f, -0.5f, 0.0f,//0
0.5f, -0.5f, 0.0f,//1
0.0f, 0.5f, 0.0f,//2
0.8f, 0.8f, 0.0f,//3
};

我们只需要按照:012 213的顺序,就可以只指定四个点画出一个四边形。

EBO里实际上就存储的是一个绘制顺序的索引

1
2
3
4
unsigned int indices[] = {
0,1,2,//第一个三角形
2,1,3//第二个三角形
};

  在OpenGL中都任何当前运行的Context,都只会处理当前的一个VAO,同时状态机也只会处理VAO去Bind一个VBO。在运行时,需要首先指定一个VAO到当前Context,然后把你想操作的VBO去绑定当前的ArrayBuffer,当VAO处理完这个VBO里的数据之后,你可以UnBind,然后把比如说另一个把存有法线数据的VBO给Bind到当前VAO,然后继续处理,所以一个当前要画的VAO可以和多个VBO去合作。EBO也是在创建之后与当前上下文中的VAO绑定,然后绘制的时候用引索的方式去画就ok:

1
2
3
4
5
6
7
8
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(1, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

//while中:
glUseProgram(shaderProgram);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

VAO、VBO、EBO的绑定在一开始申明时绑了就行,不需要在渲染循环里面多次绑定。

这样就使用另外一种方法画出了这个四边形。可喜可贺!!! :smiley:


入门篇的重点!!!Shader

  Shader:运行在GPU上的小程序,是用GLSL写成的小程序。着色器的开头要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,在这个函数中处理所有的输入变量,并将结果输出到输出变量中。GLSL定义了inout关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。

  在顶点着色器中接受的是特殊输入,他是从模型的顶点数据中直接接受输入,需要使用layout去指定顶点数据的position,我们能声明的顶点数据位置是有上限的,它一般由硬件来决定。OpenGL有16个包含4分量的顶点属性可用(0~15),但是有些硬件或许允许更多的顶点属性,可以查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限。而片段着色器必须要一个vec4的颜色输出值,它接受顶点着色器的输入的变量必须与顶点着色器的输出同名。

当然也可以忽略layout (location = 0)标识符,通过在OpenGL代码中使用glGetAttribLocation查询属性位置值(Location)

  而Uniform是一种从CPU中直接向GPU中的着色器发送数据的方式。它是全局的,意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。而且无论把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。下面就是它的使用方法,我们首先在片段着色器里面定义一句uniform vec4 ourColor,然后把ourColor设置为输出颜色,然后我们在main函数里面去调变它:

1
2
3
4
5
6
7
float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");//取得uniform的位置
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0, greenValue, 0, 1.0f);//给uniform赋值

glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

然后就可以得到一个颜色随时间变化的三角形。

下面我们再提高一下VAO挖数据的熟练度,我们可以给每个顶点数据增加一个颜色数据:

1
2
3
4
5
6
7
float vertices[] = {
//顶点 //颜色
-0.5f, -0.5f, 0.0f, 1.0f, 0, 0,
0.5f, -0.5f, 0.0f, 0, 1.0f, 0,
0.0f, 0.5f, 0.0f, 0, 0, 1.0f,
0.8f, 0.8f, 0.0f, 1.0f, 0.5f, 0.7f
};

  于是在挖顶点的相关代码中我们就要做一些改变,对于挖顶点的Attribute,我们需要改变它每次挖的步长,由于增加了三个颜色数据,现在需要挖完一组后从刚刚的起始隔六个float再挖,对于挖颜色的Attibute要设置从不同于0号的位置开始挖,然后也是挖完一组后从刚刚的起始位置隔六个float再开始挖下一组,至于最后一个参数,它的意思是初始位置的偏移量,第二个Attribute的意思就是第一组跳过前三个,从第四个开始挖:

1
2
3
4
5
6
7
// 位置
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 颜色
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);
//对于一个顶点着色器,可以有多个VAO去用它的位置,或许还有一组描述箱子的顶点坐标,你也要用同样的代码去把箱子的顶点坐标存在0号位,但是肯就会疑惑,为什么它重复使用了同一个顶点着色器里面的location,之前不是已经传过数据了吗?但是你仔细想想,它并没有重复,因为每创建一个VAO,你就使用了顶点着色器里面的locaion,然后去解析了数据,然后就存在了VAO之中,顶点着色器里面的lacation斌并有用来存数据,他只是一个指示,来把数据解析然后存在VAO中,所以重复使用位置不是什么奇怪的事。

  同时我们需要在顶点着色器中指定颜色坐标的输入:layout(location = 1) in vec3 aColor。然后把它赋予输入到片段着色器的颜色数据。最后在片段着色器中把输出颜色改为接收到的顶点着色器的输出颜色。

就可以得到下面的效果:


创建着色器类,从外部读取Shader代码!!重要重要

  在创建我们自己的着色器类时,我们想要从硬盘里面读取Shader代码(如果总是把Shader代码硬编码在main函数里会很不方便修改与Debug),这时由于CPU和硬盘处理速度的原因,我们希望在主存里面设置一个Buffer去存储从硬盘中读取的文件,然后让CPU去进行处理。还有一个点就是我们读到的其实是String,然而 OpenGL支持的是字符数组,所以我们还需要去Cast String。这就需要用到C++的字符串的处理和文件读取的一些知识,要用到C++中流概念。

  我们的想法是创建一个shader.h的类,在它的构造函数的参数中给出顶点着色器和片段着色器的文件路径,通过ifstream流去读取打开文件,然后用rdbuf()函数去读取文件中的内容。然后用str()转成string类型保存到stringsteam类型的变量中,最后用c_str()函数把string转成char* 类型。

shader.h:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Shader
{
public:
Shader(const char* vertexPath, const char* fragmentPath);
std::string vertexString;
std::string fragmentString;
const char* vertexSource = "";
const char* fragmentSource = "";
unsigned int ID;//创建的Program的位置

void use();
private:
void CheckCompileErrors(unsigned int ID, std::string type);
};

shader.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::ifstream vertexFile;
std::ifstream fragmentFile;
std::stringstream vertexSStream;
std::stringstream fragmentSString;

vertexFile.open(vertexPath);
fragmentFile.open(fragmentPath);

vertexSStream << vertexFile.rdbuf();
fragmentSString << fragmentFile.rdbuf();

vertexString = vertexSStream.str();
fragmentString = fragmentSString.str();

vertexSource = vertexString.c_str();
fragmentSource = fragmentString.c_str();

然后我们用与之前一样的步骤去创建program就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsigned int vertex, fragment;
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vertexSource, NULL);
glCompileShader(vertex);
CheckCompileErrors(vertex, "VERTEX");
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fragmentSource, NULL);
glCompileShader(fragment);
CheckCompileErrors(fragment, "FRAGMENT");

ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
CheckCompileErrors(ID, "PROGRAM");

使用shader类:

1
Shader* myShader = new Shader("vertexSource.txt", "fragmentSource.txt");

增加错误提示代码

这里来说一下增加shader代码的错误提示功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Shader::CheckCompileErrors(unsigned int ID, std::string type)
{
int success;
char infoLog[520];

if (type!="PROGRAM")
{
glGetShaderiv(ID, GL_COMPILE_STATUS, &success);
if (!success)
{
glGetShaderInfoLog(ID, sizeof(infoLog), NULL, infoLog);
std::cout << "Shader Compile Error:" << infoLog << std::endl;
}
}
else
{
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if (!success)
{
glGetProgramInfoLog(ID, sizeof(infoLog), NULL, infoLog);
std::cout << "Program Linking Error:" << infoLog << std::endl;
}
}
}

纹理(汇入stb_image.h图像处理库)

  纹理是一个非常重要的东西,可以给物体增加很多细节,它能够赋予物体,是因为有纹理坐标(uv坐标)这个东西。依据纹理坐标在纹理上进行剪裁,纹理坐标规定了物体的每个顶点对应纹理的哪个部分,这样就可以把纹理完美的贴在物体上。

纹理环绕方式(结合Unity,wrap mode)

  在图片导入unity时,引擎会自动把你的图片大小拉成2^n×2^n,相应的设置对应Unity的Non Power of 2选项。纹理的默认范围是0~1,当tiling是x=1,y=1时,warp mode没有任何影响,但是当纹理的tiling大于1,一张贴图不够大时,它如何处理边缘和外面的纹理生成方式就与wrap mode相关了,repeat模式就是重复整张贴图,clamp是在边缘找相近颜色然后来替代,就会产生一种拉伸的效果,mirror就是每贴一次去翻转uv坐标。

纹理创建方式

  在这里我们直接开始做如何给物体上多个纹理,首先在顶点着色器中要去接收纹理坐标的输入:layout(location = 2) in vec2 aTexCoord;TexCoord = aTexCoord,将纹理坐标TexCoord输出给片段着色器。在片段着色器里面,我们设置两个uniform变量去储存纹理。然后对于片段着色器的输出,我们改为用mix方法输出一个混合,FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2)。完整代码:

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
//顶点着色器
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;

out vec4 vertexColor;
out vec2 TexCoord;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
vertexColor = vec4(aColor.xyz,1.0);
TexCoord = aTexCoord;
}

//片段着色器
#version 330 core
in vec4 vertexColor;
in vec2 TexCoord;

out vec4 FragColor;

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}

对于VBO数据的改变:

1
2
3
4
5
6
7
float vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};

我们又得重新设置Attribute去对数据进行解释:

1
2
3
4
5
6
7
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
//要注意位置一定要与顶点着色器中layout设置的位置一致。

然后就是使用纹理的一般步骤:

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
unsigned int TexbufferA;//定义纹理位置
glGenTextures(1, &TexbufferA);//生成
glActiveTexture(GL_TEXTURE0);//激活纹理单元,这里后面的数字是你加载的纹理所存放的位置,等会儿把这个位置给纹理采样器就可以让纹理采样器得到这个纹理
glBindTexture(GL_TEXTURE_2D, TexbufferA);//绑定到刚刚激活的纹理单元
//这里可以去设置一些特殊的东西,比如说环绕方式,过滤方式。。。
//加载图片
stbi_set_flip_vertically_on_load(true);//反转Y轴,OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部
int width, height, nrChannel;
unsigned char* data = stbi_load("container.jpg", &width, &height, &nrChannel, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);//默认将图片加载刚刚生成的纹理单元上
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Load Image Failed" << std::endl;
}
stbi_image_free(data);//释放数据
//通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元,着色采样器就是在片段着色器里面uniform的那个东西,设置uniform的方法就是取得或者设定他的位置。
myShader->use();
glUniform1i(glGetUniformLocation(myShader->ID, "texture1"), 0);
glUniform1i(glGetUniformLocation(myShader->ID, "texture2"), 1);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
//这里的设置的采样器(uniform)的位置就是让采样器可以访问这个位置中所存放的纹理

这样就可以得到两个纹理混合之后的效果,Texture is awesome!!


变换与坐标系统

  这一小节的关于线性代数的知识,最好去深刻地理解,不要去听大学老师给你讲,学不到什么本质,推荐去看3Blue1Brown的线性代数的本质,想要打好图形学基础的话最好微积分的本质也要去看一下(这个up的知识普及视频都挺好看的,可以知道很多很酷的知识)。还有个必须要看的就是闫令琪101图形学入门课程的transform章节,里面详细讲了关于坐标与变换的知识。为了后面的实践,我们先下载glm这个图形库。

  为了实现对物体的变换,我们需要在顶点着色器里面定义一个Uniform的mat4矩阵变量,然后在main函数里面赋值之后乘上顶点输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core 								
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;

uniform mat4 transform;

out vec4 vertexColor;
out vec2 TexCoord;
void main()
{
gl_Position = transform * vec4(aPos, 1.0);//变换顶点
vertexColor = vec4(aColor.xyz,1.0);
TexCoord = aTexCoord;
}

在main函数里面,取得uniform位置后进行调变:

1
2
3
4
5
6
7
glm::mat4 trans;
//分别去位移旋转缩放
trans = glm::translate(trans, glm::vec3(-0.1f, 0, 0));
trans = glm::rotate(trans, glm::radians(45.0f), glm::vec3(0, 0, 1.0f));
trans = glm::scale(trans, glm::vec3(2.0f, 2.0f, 2.0f));
//在渲染循环里面去赋给uniform值
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "transform"), 1, GL_FALSE, glm::value_ptr(trans));//第一个参数永远是要查的uniform所在的shader的id号

构造变换矩阵(MVP)

为了将坐标从一个坐标系变换到另一个坐标系,需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。顶点坐标起始于局部空间(Local Space),在这里它称为局部坐标(Local Coordinate),它在之后会变为世界坐标(World Coordinate),观察坐标(View Coordinate),裁剪坐标(Clip Coordinate),并最后以屏幕坐标(Screen Coordinate)的形式结束。这里我们就去构造这样的三个矩阵,去把之前的3D转化到屏幕空间。

首先是在main函数中设置的三个变换矩阵:

1
2
3
4
glm::mat4 modelMat, viewMat, projMat;
modelMat = glm::rotate(modelMat, glm::radians(-55.0f), glm::vec3(1.0f, 0, 0));
viewMat = glm::translate(viewMat, glm::vec3(0, 0, -3.0f));
projMat = glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f);

然后在顶点着色器中设置三个相对应的uniform变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core 								
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aTexCoord;

uniform mat4 modelMat;
uniform mat4 viewMat;
uniform mat4 projMat;

out vec4 vertexColor;
out vec2 TexCoord;
void main()
{
gl_Position = projMat * viewMat * modelMat * vec4(aPos, 1.0);
vertexColor = vec4(aColor.xyz,1.0);
TexCoord = aTexCoord;
}

最后在渲染循环里面去设置给对应的uniform赋值:

1
2
3
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "modelMat"), 1, GL_FALSE, glm::value_ptr(modelMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "viewMat"), 1, GL_FALSE, glm::value_ptr(viewMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "projMat"), 1, GL_FALSE, glm::value_ptr(projMat));

然后就可以得到如下的效果:

(有关坐标空间的详细讲解,建议去看冯乐乐女神的shader入门精要)

画立方体

在LearnOpenGL官网把顶点直接复制之后,我们也照老规矩开始改Attribute:

1
2
3
4
5
6
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(2);
//改为用array画
glDrawArrays(GL_TRIANGLES, 0, 36);

然后为了显示正确的图案,我们需要用到Z-Buffer,它实际上存储的是深度信息,用来判断物体与物体、物体的各个面之间的遮挡关系。GLFW会自动生成这样一个缓冲,深度值存储在每个片段里面(作为片段的z值),当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖。这个过程称为深度测试(Depth Testing),它是由OpenGL自动完成的。想要使用深度测试时,我们只需要用glEnable(GL_DEPTH_TEST)去打开即可。

因为使用了深度测试,所以也需要在每次渲染迭代之前清除深度缓冲(否则前一帧的深度信息仍然保存在缓冲中)。就像清除颜色缓冲一样,可以通过在glClear函数中指定DEPTH_BUFFER_BIT位来清除深度缓冲。

而在Unity中,深度测试对应的就是clear flag的depth only

画更多的立方体

先把十个立方体的位置信息给复制上去,然后我们调用glDrawArrays 10次,但这次在我们渲染之前每次传入一个不同的模型矩阵到顶点着色器中,我们将会在游戏循环中创建一个小的循环用不同的模型矩阵渲染我们的物体10次。

1
2
3
4
5
6
7
8
9
10
for (int i = 0; i < 10; i++)
{
glm::mat4 modelMat;
modelMat = glm::translate(modelMat, cubePositions[i]);
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "modelMat"), 1, GL_FALSE, glm::value_ptr(modelMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "viewMat"), 1, GL_FALSE, glm::value_ptr(viewMat));
glUniformMatrix4fv(glad_glGetUniformLocation(myShader->ID, "projMat"), 1, GL_FALSE, glm::value_ptr(projMat));

glDrawArrays(GL_TRIANGLES, 0, 36);
}

这样就可以画出十个小箱子:

在这里就要提到DrawCall,drawcall是CPU对底层图形绘制接口的调用命令GPU执行渲染操作,每次绘制时,CPU都需要调用drawcall,每个drawcall都需要很多准备工作,检测渲染状态、提交渲染数据、提交渲染状态。而GPU本身具有很强大的计算能力,可以很快就处理完渲染任务。

但是当DrawCall过多,CPU就会很多额外开销用于准备工作,这里我们就使用了是个drawcall命令,所以我们要采取一个优化,只使用一个drawcall就画出这十个立方体。由于这篇文章的篇幅已经过大,我会在另外一篇文章给出具体的优化方法,就是基于GPU Instance实现大量物体的渲染和绘制


摄像机(封装Camera Class)

OpenGL本身没有摄像机(Camera)的概念,但我们可以通过把场景中的所有物体往相反方向移动的方式来模拟出摄像机,产生一种我们在移动的感觉,而不是场景在移动。现在我来教你如何在OpenGL中配置一个摄像机,并且将会讨论FPS风格的摄像机,让你能够在3D场景中自由移动。也会讲键盘和鼠标输入,最终完成一个自定义的摄像机类。

欧拉角

欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),俯仰角是描述我们如何往上或往下看的角,偏航角表示我们往左和往右看的程度,滚转角代表我们如何翻滚摄像机。每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量。对于摄像机系统来说,只需要对俯仰角和偏航角进行描述。

LookAt矩阵

我们获取摄像机的三个轴,再加上一个摄像机的位置向量,就可以得到摄像机的观察矩阵,首先我们可以得到摄像机的位置,然后用位置减去我们所定义的原点,就可以得到我们想得到的位置向量,再把位置向量和世界的Up向量做叉积,就可以得到指向右边的垂直向量,再把右边向量和位置向量叉乘就可以得到摄像机的up向量,于是这几个向量就都得到了。

我们创建一个Camera类,然后在构造函数里面进行赋值和计算:

1
2
3
4
5
6
7
8
Camera::Camera(glm::vec3 position, glm::vec3 target, glm::vec3 worldup)
{
Position = position;
WorldUp = worldup;
Forward = glm::normalize(target - position);
Right = glm::normalize((glm::cross(Forward, WorldUp)));
Up = glm::normalize(glm::cross(Forward, Right));
}

然后我们直接用OpenGL的lookat方法就可以把这个矩阵给构造出来:

1
2
3
4
glm::mat4 Camera::GetViewMatrix()
{
return glm::lookAt(Position, Position + Forward, WorldUp);
}

然后我们就可以把之前的view矩阵替换掉,viewMat = camera.GetViewMatrix(),并且把它放到渲染循环里面,这样就做出了摄像机的视角。

旋转矩阵

之前的构造函数无法做出旋转量的改变,接下来做另外一个构造函数:Camera(glm::vec3 position, float pitch, float yaw, glm::vec3 worldup)。我们想要使用之前提到过的欧拉角的方式,来计算Camera的Forward向量的改变,具体的推导计算方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
Camera::Camera(glm::vec3 position, float pitch, float yaw, glm::vec3 worldup)
{
Position = position;
WorldUp = worldup;
Pitch = pitch;
Yaw = yaw;
Forward.x = glm::cos(Pitch) * glm::sin(Yaw);
Forward.y = glm::sin(Pitch);
Forward.z = glm::cos(Pitch) * glm::cos(Yaw);
Right = glm::normalize((glm::cross(Forward, WorldUp)));
Up = glm::normalize(glm::cross(Forward, Right));
}

鼠标输入监听

偏航角和俯仰角是通过鼠标移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。首先要告诉GLFW,它应该隐藏光标,并捕捉(Capture)它。我们使用glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED)就可以实现这个,直接把它放在开窗成功之后就行。

然后我们就要使用回调函数去监听鼠标的输入,glfwSetCursorPosCallback(window, mouse_callback),这个函数会告诉OpenGL每当鼠标有输入就调用我们写的mouse_callback回调函数去对鼠标的输入做处理(这个函数是用来计算鼠标的移动量):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void mouse_callback(GLFWwindow* window, double xpos, double ypos) {
if (firstMouse==true)
{//第一次输入时默认为0
lastX = xpos;
lastY = ypos;
firstMouse = false;
}
float deltaX, deltaY;
//计算偏移量
deltaX = xpos - lastX;
deltaY = ypos - lastY;

lastX = xpos;
lastY = ypos;

camera.ProcessMouseMovement(deltaX, deltaY);
}

然后在把得到的偏移量用于更新Camera的Forward向量,其中deltaX对应的是水平偏移量Yaw,deltaY对应的是俯仰角偏移量Pitch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void Camera::ProcessMouseMovement(float deltaX, float deltaY)
{
Pitch -= deltaY * SenseX;
Yaw -= deltaX * SenseY;

if (Pitch>89.0f)
{
Pitch = 89.0f;
}
if (Pitch<-89.0f)
{
Pitch = -89.0f;
}
UpdateCameraVectors();
}

void Camera::UpdateCameraVectors()
{
Forward.x = glm::cos(glm::radians(Pitch)) * glm::sin(glm::radians(Yaw));
Forward.y = glm::sin(glm::radians(Pitch));
Forward.z = glm::cos(glm::radians(Pitch)) * glm::cos(glm::radians(Yaw));
Right = glm::normalize((glm::cross(Forward, WorldUp)));
Up = glm::normalize(glm::cross(Forward, Right));
}

在把鼠标的偏移套接到摄像机的Forward后,我们来实现FPS的视角移动,我们需要自定义前方,而这个前方就是之前组装出来的Forward,当我们按下W或者S时,在Forward上乘上一个数值就行。然后就是当我们按下A或者D的时候就使用刚刚计算得到的Right向量就可以以同样的方式去实现在一个平面上的左右移动。

在Camera.h里面定义speedZ和speedY,然后在ProcessInput里面去监听键盘的输入,再分别给刚刚的两个变量赋值:

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
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
glfwSetWindowShouldClose(window, true);
}

if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS) {
camera.speedZ = 1.0f;
}
else if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS) {
camera.speedZ = -1.0f;
}
else if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
{
camera.speedY = -1.0f;
}
else if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
{
camera.speedY = 1.0f;
}
else
{
camera.speedZ = 0;
camera.speedY = 0;
}
}

void Camera::UpdateCameraPos()
{//用前和右向量去调变摄像机的位置
Position += Forward * speedZ * 0.1f;
Position += Right * speedY * 0.1f;
}

到此为止,FPS的移动方式我们也已经实现了!

Graphics is awesome!未完待续。。。。。


Author: cdc
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source cdc !
  TOC