目录
一、概述
OpenGL到底是什么
一般它被认为是一个API(Application Programming Interface, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。
OpenGL规范严格规定了每个函数该如何执行,以及它们的输出值。实际的OpenGL库的开发者通常是显卡的生产商。
OpenGL库是用C语言写的,同时也支持多种语言的派生,但其内核仍是一个C库。
核心模式与立即渲染模式
早期的OpenGL使用立即渲染模式,OpenGL的大多数功能都被库隐藏起来,开发者很少有控制OpenGL如何进行计算的自由。
教程面向OpenGL3.3的核心模式。
扩展
OpenGL的一大特性就是对扩展(Extension)的支持,当一个显卡公司提出一个新特性或者渲染上的大优化,通常会以扩展的方式在驱动中实现。
状态机
OpenGL自身是一个巨大的状态机(State Machine):一系列的变量描述OpenGL此刻应当如何运行。OpenGL的状态通常被称为OpenGL上下文(Context)。
二、窗口
int main()
{
glfwInit();//初始化
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//glfwWindowHint函数的第一个参数代表选项的名称,我们可以从很多以GLFW_开头的枚举值中选择;第二个参数接受一个整型,用来设置这个选项的值。
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
//窗口的宽和高,窗口的名称
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);//回调
return 0;
}
GLAD是用来管理OpenGL的函数指针的,所以在调用任何OpenGL的函数之前我们需要初始化GLAD。
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
视口
告诉OpenGL渲染窗口的尺寸大小即视口(Viewport)
glViewport(0, 0, 800, 600);
glViewport函数前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度(像素)。
当用户改变窗口的大小的时候,视口也应该被调整。我们可以对窗口注册一个回调函数(Callback Function),它会在每次窗口大小被调整的时候被调用。
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
//注册
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
渲染循环(Render Loop)
while(!glfwWindowShouldClose(window))
//在我们每次循环的开始前检查一次GLFW是否被要求退出
//如果是的话该函数返回true然后渲染循环便结束了
{
processInput(window);//检测特定的键是否被按下
glfwSwapBuffers(window);//交换颜色缓冲
glfwPollEvents();
//检查有没有触发什么事件(比如键盘输入、鼠标移动等)
//更新窗口状态,并调用对应的回调函数
}
glfwTerminate();//clearing all
return 0;
双缓冲(Double Buffer)
使用单缓冲绘图时可能会存在图像闪烁的问题。前缓冲保存着最终输出的图像,它会在屏幕上显示;而所有的的渲染指令都会在后缓冲上绘制。当所有的渲染指令执行完毕后,我们交换(Swap)前缓冲和后缓冲,这样图像就立即呈显出来。
输入
void processInput(GLFWwindow *window)
//一个窗口以及一个按键作为输入。这个函数将会返回这个按键是否正在被按下。
{
if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
渲染
所有的渲染(Rendering)操作放到渲染循环中,因为我们想让这些渲染指令在每次渲染循环迭代的时候都能被执行。
在每个新的渲染迭代开始的时候我们总是希望清屏,否则我们仍能看见上一次迭代的渲染结果,调用glClear函数来清空屏幕的颜色缓冲,可能的缓冲位有GL_COLOR_BUFFER_BIT,GL_DEPTH_BUFFER_BIT和GL_STENCIL_BUFFER_BIT。由于现在我们只关心颜色值,所以我们只清空颜色缓冲。
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
当调用glClear函数,清除颜色缓冲之后,整个颜色缓冲都会被填充为glClearColor里所设置的颜色。
三、三角形
- 顶点数组对象:Vertex Array Object,VAO
- 顶点缓冲对象:Vertex Buffer Object,VBO
- 索引缓冲对象:Element Buffer Object,EBO或Index Buffer Object,IBO
OpenGL的大部分工作都是关于把3D坐标转变为适应你屏幕的2D像素,3D坐标转为2D坐标的处理过程是由OpenGL的图形渲染管线管理。
第一部分把你的3D坐标转换为2D坐标,第二部分是把2D坐标转变为实际的有颜色的像素。
OpenGL着色器是用OpenGL着色器语言(OpenGL Shading Language, GLSL)写成的
图形渲染管线
- 以数组的形式传递3个3D坐标作为图形渲染管线的输入用来表示一个三角形,这个数组叫做顶点数据(Vertex Data)
- 顶点着色器(Vertex Shader),它把一个单独的顶点作为输入。顶点着色器主要的目的是把3D坐标转为另一种3D坐标
- 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状;
- 几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。
- 光栅化阶段(Rasterization Stage)把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
- 片段着色器的主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
顶点输入
在OpenGL中指定的所有坐标都是3D坐标(x、y和z),OpenGL仅当3D坐标在3个轴(x、y和z)上都为-1.0到1.0的范围内时才处理它。
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO); //使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
//把之前定义的顶点数据复制到缓冲的内存中
//第一个参数是目标缓冲的类型,第二个参数指定传输数据的大小(以字节为单位)
//第三个参数是我们希望发送的实际数据。
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;//设定了输入变量的位置值
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
//一个向量有最多4个分量,每个分量值都代表空间中的一个坐标
//它们可以通过vec.x、vec.y、vec.z和vec.w来获取。
我们必须把位置数据赋值给预定义的gl_Position变量,它在幕后是vec4
类型的。
gl_Position设置的值会成为该顶点着色器的输出。
片段着色器
#version 330 core
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
链接顶点属性
解析顶点数据:
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
//第二个参数指定顶点属性的大小,第三个参数指定数据的类型
//第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。
//最后一个参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。
使用glEnableVertexAttribArray,以顶点属性位置值作为参数,启用顶点属性;顶点属性默认是禁用的。
顶点数组对象
顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。
OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。
// 1. 绑定VAO
glBindVertexArray(VAO);
// 2. 把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// ..:: 绘制代码(渲染循环中) :: ..
// 4. 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();
//绘制多个物体时,你首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。
索引缓冲对象
索引缓冲对象(Element Buffer Object,EBO,也叫Index Buffer Object,IBO)它专门储存索引,OpenGL调用这些顶点的索引来决定该绘制哪个顶点。
定义(不重复的)顶点,和绘制出矩形所需的索引
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
unsigned int indices[] = { // 注意索引从0开始!
0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};
unsigned int EBO;
glGenBuffers(1, &EBO);//创建索引缓冲对象
//先绑定EBO然后用glBufferData把索引复制到缓冲里
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
最后的初始化和绘制代码现在看起来像这样:
// ..:: 初始化代码 :: ..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 设定顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
[...]
// ..:: 绘制代码(渲染循环中) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0)
第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样。
第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点。
第三个参数是索引的类型,这里是GL_UNSIGNED_INT。
glBindVertexArray(0);
线框模式(Wireframe Mode)
要想用线框模式绘制你的三角形,你可以通过
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
四、着色器
着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。
着色器是使用一种叫GLSL的类C语言写成的。
数据类型
GLSL中包含C等其它语言大部分的默认基础数据类型:int
、float
、double
、uint
和bool
。
GLSL中的向量是一个可以包含有1、2、3或者4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。
一个向量的分量可以通过vec.x
这种方式获取,这里x
是指这个向量的第一个分量。GLSL也允许你对颜色使用rgba
,或是对纹理坐标使用stpq
访问相同的分量。
输入与输出
GLSL定义了in
和out
关键字,顶点着色器应该接收的是一种特殊形式的输入使用location
这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。另一个例外是片段着色器,它需要一个vec4
颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。
//vs
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
void main()
{
gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
//fs
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
void main()
{
FragColor = vertexColor;
}
Uniform
uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量
这个uniform现在还是空的;我们还没有给它添加任何数据,所以下面我们就做这件事。我们首先需要找到着色器中uniform属性的索引/位置值。当我们得到uniform的索引/位置值后,我们就可以更新它的值了。
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
//用glGetUniformLocation查询uniform ourColor的位置值。
//我们为查询函数提供着色器程序和uniform的名字
//返回-1就代表没有找到这个位置值。
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
//通过glUniform4f函数设置uniform值。
//更新一个uniform之前你必须先使用程序调用glUseProgram
更多属性
float vertices[] = {
// 位置 // 颜色
0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 顶部
};
由于现在有更多的数据要发送到顶点着色器,我们有必要去调整一下顶点着色器,使它能够接收颜色值作为一个顶点属性输入。
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1
因为我们添加了另一个顶点属性,并且更新了VBO的内存,我们就必须重新配置顶点属性指针。
// 位置属性
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);
从文件读取
我们使用C++文件流读取着色器内容,储存到几个string
对象里:
Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. 从文件路径中获取顶点/片段着色器
std::string vertexCode;
std::string fragmentCode;
std::ifstream vShaderFile;
std::ifstream fShaderFile;
// 保证ifstream对象可以抛出异常:
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;
// 读取文件的缓冲内容到数据流中
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// 关闭文件处理器
vShaderFile.close();
fShaderFile.close();
// 转换数据流到string
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch(std::ifstream::failure e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
}
const char* vShaderCode = vertexCode.c_str();
const char* fShaderCode = fragmentCode.c_str();
下一步,我们需要编译和链接着色器。
// 2. 编译着色器
unsigned int vertex, fragment;
int success;
char infoLog[512];
// 顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// 打印编译错误(如果有的话)
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{
glGetShaderInfoLog(vertex, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};
我们会把着色器类全部放在在头文件里
class Shader
{
public:
// 程序ID
unsigned int ID;
// 构造器读取并构建着色器
Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
// 使用/激活程序
void use();
// uniform工具函数
void setBool(const std::string &name, bool value) const;
void setInt(const std::string &name, int value) const;
void setFloat(const std::string &name, float value) const;
};
//
void use()
{
glUseProgram(ID);
}
void setFloat(const std::string &name, float value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}
...
// 着色器程序
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 << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}
// 删除着色器,它们已经链接到我们的程序中了,已经不再需要了
glDeleteShader(vertex);
glDeleteShader(fragment);
着色器类使用
Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
...
while(...)
{
ourShader.use();
ourShader.setFloat("someUniform", 1.0f);
DrawStuff();
}
五、纹理
纹理是一个2D图片(甚至也有1D和3D的纹理),每个顶点就会关联着一个纹理坐标(Texture Coordinate),用来标明该从纹理图像的哪个部分采样。
纹理坐标在x和y轴上,范围为0到1之间(注意我们使用的是2D纹理图像)。使用纹理坐标获取纹理颜色叫做采样(Sampling)。
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};
纹理环绕方式
第一个参数指定了纹理目标;我们使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是WRAP
选项,并且指定S
和T
轴。最后一个参数需要我们传递一个环绕方式(Wrapping)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
纹理过滤
图片不断放大你会发现它是由无数像素点组成的,这个点就是纹理像素;
纹理坐标是你给模型顶点设置的那个数组,OpenGL以这个顶点的纹理坐标数据去查找纹理图像上的像素,然后进行采样提取纹理像素的颜色。
GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。OpenGL会选择中心点最接近纹理坐标的那个像素。
GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。
差别:GL_NEAREST产生了颗粒状的图案,我们能够清晰看到组成纹理的像素,而GL_LINEAR能够产生更平滑的图案,很难看出单个的纹理像素。GL_LINEAR可以产生更真实的输出。
当进行放大(Magnify)和缩小(Minify)操作的时候可以设置纹理过滤的选项,可以在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
多级渐远纹理Mipmap
假设我们有一个包含着上千物体的大房间,每个物体上都有纹理。有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难。
它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。
切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
加载与创建纹理
stb_image.h
库
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
通过定义STB_IMAGE_IMPLEMENTATION,预处理器会修改头文件,让其只包含相关的函数定义源码
生成纹理
unsigned int texture;
glGenTextures(1, &texture);
//生成纹理的数量,然后把它们储存在第二个参数的unsigned int数组中
glBindTexture(GL_TEXTURE_2D, texture);
// 为当前绑定的纹理对象设置环绕、过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
// 加载并生成纹理
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
//纹理目标(Target),纹理指定多级渐远纹理的级别,纹理储存为何种格式.
//第四个和第五个参数设置最终的纹理的宽度和高度
//第七第八个参数定义了源图的格式和数据类型。
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);//释放图像的内存
应用纹理
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 // 左上
};
...
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);
调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器
layout (location = 2) in vec2 aTexCoord;
out vec2 TexCoord;
void main()
{
TexCoord = aTexCoord;
}
片段着色器应该接下来会把输出变量TexCoord作为输入变量。
GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀
声明一个uniform sampler2D把一个纹理添加到片段着色器中
fs:
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
//它第一个参数是纹理采样器,第二个参数是对应的纹理坐标。
现在只剩下在调用glDrawElements之前绑定纹理了,它会自动把纹理赋值给片段着色器的采样器:
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
纹理单元
一个纹理的位置值通常称为一个纹理单元,纹理单元的主要目的是让我们在着色器中可以使用多于一个的纹理。通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);
激活纹理单元之后,接下来的glBindTexture函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元GL_TEXTURE0默认总是被激活,OpenGL至少保证有16个纹理单元。
#version 330 core
...
uniform sampler2D texture1;
uniform sampler2D texture2;
void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}
我们仍然需要编辑片段着色器来接收另一个采样器最终输出颜色现在是两个纹理的结合。
渲染流程:
先绑定两个纹理到对应的纹理单元
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, texture1); glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, texture2);
通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元。
ourShader.use(); // 不要忘记在设置uniform变量之前激活着色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置
ourShader.setInt("texture2", 1); // 或者使用着色器类设置
纹理上下颠倒:OpenGL要求y轴
0.0
坐标是在图片的底部的,但是图片的y轴0.0
坐标通常在顶部。stbi_set_flip_vertically_on_load(true);
六、变换
向量
向量最基本的定义就是一个方向。向量有一个方向(Direction)和大小(Magnitude,也叫做强度或长度)。标量(Scalar)只是一个数字(或者说是仅有一个分量的向量)。
两个向量相乘是一种很奇怪的情况。普通的乘法在向量上是没有定义的,因为它在视觉上是没有意义的。但是在相乘的时候我们有两种特定情况可以选择:一个是点乘另一个是叉乘(Cross Product)
叉乘只在3D空间中有定义,它需要两个不平行向量作为输入,生成一个正交于两个输入向量的第三个向量。
矩阵
七、坐标系统
为了将坐标从一个坐标系变换到另一个坐标系,我们需要用到几个变换矩阵,最重要的几个分别是模型(Model)、观察(View)、投影(Projection)三个矩阵。
- 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
- 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
- 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
- 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
- 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。
正射投影
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);
前两个参数指定了平截头体的左右坐标,第三和第四参数指定了平截头体的底部和顶部。通过这四个参数我们定义了近平面和远平面的大小,然后第五和第六个参数则定义了近平面和远平面的距离。这个投影矩阵会将处于这些x,y,z值范围内的坐标变换为标准化设备坐标。
透视投影
由于透视,这两条线在很远的地方看起来会相交。这正是透视投影想要模仿的效果,它是使用透视投影矩阵来完成的。
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
视野/宽高比/平截头体的近和远平面
glm::perspective
所做的其实就是创建了一个定义了可视空间的大平截头体,任何在这个平截头体以外的东西最后都不会出现在裁剪空间体积内,并且将会受到裁剪。
当使用正射投影时,每一个顶点坐标都会直接映射到裁剪空间中而不经过任何精细的透视除法.
3D
在开始进行3D绘图时,我们首先创建一个模型矩阵。这个模型矩阵包含了位移、缩放与旋转操作,它们会被应用到所有物体的顶点上,以变换它们到全局的世界空间。
glm::mat4 model;
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));
glm::mat4 view;
// 注意,我们将矩阵向我们要进行移动场景的反方向移动。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));
glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);
传入着色器
#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);
...
}
将矩阵传入着色器
int modelLoc = glGetUniformLocation(ourShader.ID, "model")); glUniformMatrix4fv(modelLoc, 1, GL_FALSE, glm::value_ptr(model));
...
Z缓冲Z-buffer
GLFW会自动为你生成这样一个缓冲,深度值存储在每个片段里面(作为片段的z值),当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖。这个过程称为深度测试(Depth Testing),它是由OpenGL自动完成的。
启用深度测试:
glEnable(GL_DEPTH_TEST);
因为我们使用了深度测试,我们也想要在每次渲染迭代之前清除深度缓冲
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
八、摄像机
当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标。
摄像机位置
获取摄像机位置很简单。摄像机位置简单来说就是世界空间中一个指向摄像机位置的向量。z轴是从屏幕指向你。我们希望摄像机向后移动,我们就沿着z轴的正方向移动。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
摄像机方向
指的是摄像机指向哪个方向。摄像机指向z轴负方向,但我们希望方向向量(Direction Vector)指向摄像机的z轴正方向。
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘。现在我们已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量就相对简单了:把右向量和方向向量进行叉乘:
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f);
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection));
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);
Look At
用这3个轴外加一个平移向量来创建一个矩阵,并且你可以用这个矩阵乘以任何向量来将其变换到那个坐标空间。
我们要做的只是定义一个摄像机位置,一个目标位置和一个表示世界空间中的上向量的向量(我们计算右向量使用的那个上向量)。
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));
自由移动
我们首先将摄像机位置设置为之前定义的cameraPos。方向是当前的位置加上我们刚刚定义的方向向量。这样能保证无论我们怎么移动,摄像机都会注视着目标方向。
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
void processInput(GLFWwindow *window)
{
...
float cameraSpeed = 0.05f; // adjust accordingly
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}
当我们按下WASD键的任意一个,摄像机的位置都会相应更新。如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。如果我们希望向左右移动,我们使用叉乘来创建一个右向量(Right Vector),并沿着它相应移动就可以了。
目前我们的移动速度是个常量。
图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值。结果就是,如果我们的deltaTime很大,就意味着上一帧的渲染花费了更多时间,所以这一帧的速度需要变得更高来平衡渲染所花去的时间。
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
void processInput(GLFWwindow *window)
{
float cameraSpeed = 2.5f * deltaTime;
...
}
视角移动-加入鼠标
为了能够改变视角,我们需要根据鼠标的输入改变cameraFront向量。
欧拉角:欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll)。
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
// 译注:direction代表摄像机的前轴(Front),这个前轴是和本文第一幅图片的第二个摄像机的方向向量是相反的
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置与上一帧的位置相差多少。如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。
- 计算鼠标距上一帧的偏移量。
- 把偏移量添加到摄像机的俯仰角和偏航角中。
- 对偏航角和俯仰角进行最大和最小值的限制。
- 计算方向向量。
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);//隐藏光标,并捕捉(Capture)它
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
glfwSetCursorPosCallback(window, mouse_callback);
我们必须先在程序中储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心
float lastX = 400, lastY = 300;
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05f;
xoffset *= sensitivity;//灵敏度
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了,对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
通过俯仰角和偏航角来计算以得到真正的方向向量
glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);
缩放
使用鼠标的滚轮来放大
glfwSetScrollCallback(window, scroll_callback);//注册鼠标滚轮的回调函数
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
if(fov >= 1.0f && fov <= 45.0f)
fov -= yoffset;//yoffset值代表我们竖直滚动的大小
if(fov <= 1.0f)
fov = 1.0f;
if(fov >= 45.0f)//45.0f是默认的视野值
fov = 45.0f;
}
我们现在在每一帧都必须把透视投影矩阵上传到GPU,但现在使用fov变量作为它的视野:
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);
词汇表
- GLAD: 一个拓展加载库,用来为我们加载并设定所有OpenGL函数指针
- 视口(Viewport): 我们需要渲染的窗口
- 图形管线(Graphics Pipeline): 一个顶点在呈现为像素之前经过的全部过程
- 着色器(Shader): 一个运行在显卡上的小型程序
- 顶点数组对象(Vertex Array Object): 存储缓冲区和顶点属性状态
- 顶点缓冲对象(Vertex Buffer Object): 一个调用显存并存储所有顶点数据供显卡使用的缓冲对象
- 索引缓冲对象(Element Buffer Object): 一个存储索引供索引化绘制使用的缓冲对象
- 纹理缠绕(Texture Wrapping): 定义了一种当纹理顶点超出范围(0, 1)时指定OpenGL如何采样纹理的模式
- 纹理过滤(Texture Filtering): 定义了一种当有多种纹素选择时指定OpenGL如何采样纹理的模式
- 多级渐远纹理(Mipmaps): 被存储的材质的一些缩小版本,根据距观察者的距离会使用材质的合适大小
- 纹理单元(Texture Units): 通过绑定纹理到不同纹理单元从而允许多个纹理在同一对象上渲染
- GLM: 一个为OpenGL打造的数学库
- 局部空间(Local Space): 一个物体的初始空间。所有的坐标都是相对于物体的原点的
- 世界空间(World Space): 所有的坐标都相对于全局原点
- 观察空间(View Space): 所有的坐标都是从摄像机的视角观察
- 裁剪空间(Clip Space): 该空间应用了投影。这个空间应该是一个顶点坐标最终的空间,作为顶点着色器的输出
- 屏幕空间(Screen Space): 所有的坐标都由屏幕视角来观察
- LookAt矩阵: 一种特殊类型的观察矩阵,它创建了一个坐标系,其中所有坐标都根据从一个位置正在观察目标的用户旋转或者平移