上回书说到,OpenGL的渲染管线被划分为数个环节,每个环节的工作由一些专门的、相对简短的程序,即着色器来完成。编写着色器的语言是GLSL,它具有类似C语言的语法,专为图形渲染设计,因而具有许多对于向量和矩阵的有效操作。为了之后对OpenGL的深入了解,学习GLSL是必不可少的一环。
着色器的基本结构
从上一篇文章可以看出,不同的着色器可能有着不同的格式,顶点着色器通过layout=x
的方式接收顶点属性输入(通常最多为16个),并将结果输出到gl_Position
等变量中,而片段着色器从之前的着色器接收输入,并需要声明out
变量用于输出。尽管如此,大部分着色器都有着类似的结构:
#version version_number
in type in_variable_name;
in type in_variable_name;
out type out_variable_name;
uniform type uniform_name;
void main()
{
// 对输入变量进行处理
...
// 输出结果
out_variable_name = weird_stuff_we_processed;
}
GLSL的数据类型
GLSL是一门类C语言,数据类型方面也不例外。GLSL中有int
、uint
、float
、double
、bool
等数据类型,此外还有两种为图形渲染量身定制的数据类型:vector
和matrix
。vector
是可以包含2、3、4个分量的向量,每个分量的数据类型是相同的。前面提到的5种基本数据类型都可以用于创建vector。vector的声明形式一般是TvecN
,其中T是类型,N是分量数。默认的vecn
表示n个分量的float
向量,而dvecn
、ivecn
、uvecn
、bvecn
则表示其他几种类型的向量。
OpenGL的向量操作非常灵活。向量的分量可以使用类似vec.x
这样的方式获取。vec4
的分量有xyzw,OpenGL也允许对颜色使用rgba,对纹理使用stpq。更加方便的是,开发者可以任意排列xyzw的顺序来得到一个新的向量,也可以在构造向量时直接使用另一个向量作为新向量的一部分:
vec2 someVec; // someVec = (1, 2)
vec4 differentVec = someVec.xyxx; // differentVec = (1, 2, 1, 1)
vec3 anotherVec = differentVec.zyw; // anotherVec = (1, 2, 1)
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
// otherVec = (1, 1, 1, 1) + (2, 1, 1, 2) = (3, 2, 2, 3)
vec4 newVec = vec4(someVec, 3.0, 4.0) // newVec = (1, 2, 3, 4)
这种灵活的向量操作使得着色器代码可以更加简短,而省去了许多声明中间临时变量的工作。
三种重要的变量修饰符
输入、输出修饰符
除了顶点着色器直接从顶点数据中获取输入以外,其他着色器都是从上一环节的着色器获取输入,以及把处理后的结果输出到下一环节的着色器。为此,OpenGL设计了in
、out
修饰符用来标明变量属于输入或输出变量。只要输出变量与下一个着色器的输入变量相匹配(在不使用layout指定输出的位置时,需要前后着色器相应的类型与变量名都相同才被视为匹配),就可以进行传输。
在上一篇文章中,三角形的颜色是通过片段着色器来指定的。而我们可以通过变量的传输,让顶点着色器来指定颜色:
// 顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
void main()
{
gl_Position = vec4(aPos, 1.0);
// 像之前说的,这里不必使用aPos.x等分量,而可以直接用aPos作为构造参数
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
// 片段着色器
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
void main()
{
FragColor = vertexColor;
}
顶点着色器指定了颜色向量vertexColor
,并传给了片段着色器。事实上vertexColor
也可以来自顶点数据,只要在顶点数据中加入颜色数据,就可以给每个顶点分别指定颜色。编译运行后,上篇中的矩形被改为了暗红色:
Uniform修饰符
目前为止我们的颜色数据要么在片段着色器中指定,要么在顶点着色器中指定并传递给片段着色器。OpenGL中还存在一种方式可以让任何着色器在任何阶段都可以获取变量。当在变量前添加uniform
关键字时,这个变量就是一个为所有着色器共享的全局变量了。例如,我们可以在片段着色器中定义一个有关颜色的uniform变量:
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量
void main()
{
FragColor = ourColor;
}
在此处定义后,我们可以在任何着色器中使用这个变量。但是如果一个uniform变量被定义后却从未被使用,OpenGL将在编译时移除它,且不会产生任何通知,这可能导致一些严重的问题。
要给一个uniform变量赋值,我们可以使用一些函数找到着色器中uniform变量的位置,从而更新它的值。为了体现出颜色值的更新,我们可以将颜色设为时间的函数。
float timeValue = glfwGetTime(); // 用于获得当前运行的时间
float greenValue = (sin(timeValue) / 2.0f) + 0.5f; // 将时间转化为G通道的颜色值
// 找到uniform变量 ourColor 的位置,变量不存在时会返回-1
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram); // 更新着色器的uniform变量前必须启用着色器
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f); // 设置uniform变量的值
关于glUniform4f
OpenGL的核心是C语言,这意味着它不支持多态,也就没有函数重载。而uniform变量可以是各种类型的,因此更新uniform变量的函数glUniform
也需要有各种各样的衍生,而不是重载。OpenGL的设计是在函数后面加各种后缀。例如glUniformf
表示这个uniform变量是单个float,glUniformi
表示这个uniform变量是单个int变量,glUniformfv
表示这个uniform变量是一个float vector。
每个着色器中都有一个uniform存储区,专门用于存储uniform变量。调用glUniform
时,第一个参数就是这个uniform变量在存储区中的位置,后面的则是与这个uniform变量类型匹配的要更新的值。
编译运行后我们就可以看到颜色随时间变化的矩形了:
让顶点来决定颜色
前面我们提到可以让顶点着色器来决定颜色,并把颜色传给片段着色器。一个自然的想法是将颜色作为一种顶点属性输入到顶点着色器中,这样就可以对每个顶点指定不同的颜色。
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 // 顶部(蓝色)
};
现在我们有更多的属性输入到顶点着色器中了。因此我们需要调整着色器的输入变量,否则OpenGL并不知道那些float值是颜色。具体而言,我们需要添加一个输入位置:
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的位置为0
layout (location = 1) in vec3 aColor; // 颜色变量的位置为1
out vec3 vertexColor;
void main()
{
gl_Position = vec4(aPos, 1.0);
vertexColor = aColor;
}
// 在片段着色器中使用这个变量
in vec3 vertexColor;
...
FragColor = vec4(vertexColor, 1.0);
...
回忆上一篇文章,除了在着色器代码中设置输入位置以外,还需要在外部设置好顶点属性的大小,输入步长、偏移量等(顶点属性指针)。这些信息被存储在VAO中,要输入的顶点数据被储存在VBO中,而我们通过EBO来指挥OpenGL将哪些点绘制成三角形。
// 配置EBO
unsigned int indices[] = {
0, 1, 2
};
...
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 设置并启用两种顶点属性,注意步长是6 * sizeof(float),
// 因为现在一个顶点属性的大小是6个float
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);
//渲染循环中
glBindVertexArray(VAO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
这样我们就可以画出三个顶点颜色各不相同的三角形了。
为什么上图的三角形是渐变色的
可以注意到,我们在着色器中仅仅给三个顶点指定了三种颜色,并要求OpenGL渲染一个由这三个顶点决定的三角形。我们并没有规定三角形内部的点的颜色应当如何决定,而看起来OpenGL基于内部点的位置对三个顶点的颜色进行了重心坐标插值,来决定这个点的颜色,这使得三角形看起来是渐变色的。
出现这种状况的原因是OpenGL默认情况下会使用平滑插值(smooth interpolation),也就是用重心坐标插值的方式决定三角形内部点的颜色。开发者也可以使用另一种插值限定符,将插值模式改为平面插值(flat interpolation),使得三角形的颜色由其中一个顶点(被称为激发顶点(Provoking Vertex))决定。默认情况下激发顶点是该三角形中最后一个输入的顶点。例如,我们可以这样修改着色器代码来修改插值模式:
// 顶点着色器
flat out vec3 vertexColor;
...
// 片段着色器
flat in vec3 vertexColor;
out vec4 FragColor
...
添加了这样的限定符后,编译运行的结果就会变为纯色三角形(颜色同最后一个输入的顶点相同,即蓝色):
激发顶点的选择规则可以更改,例如使用glProvokingVertex(GL_FIRST_VERTEX_CONVENTION);
,就会将选择规则改为第一个输入的顶点。
管理着色器
到目前为止,我们的着色器代码还只是在cpp源码中声明两个静态字符串,并将其注入着色器对象中。当着色器数量增加时这明显不利于管理。因此我们可以创建一个着色器类,它将能够从文件中读取着色器代码并注入着色器中,还能负责着色器的错误检测。
#ifndef SHADER_H // 预处理指令可以保证只有在这个头文件没被包含时
#define SHADER_H // 才定义以下内容,可以防止链接冲突
#include <glad/glad.h>; // 包含glad来获取所有的必须OpenGL头文件
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
class Shader
{
public:
// 程序ID
unsigned int ID;
// 构造函数读取并构建着色器,需要一个顶点着色器源文件和一个片段着色器源文件
Shader(const char* vertexPath, const char* 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;
};
#endif
接下来我们实现Shader类的构造函数和其他成员函数
Shader(const char* vertexPath, const char* fragmentPath)
{
// 1. 用C++从着色器源文件中获取源码
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;
// 将文件读入字符串流中
vShaderStream << vShaderFile.rdbuf();
fShaderStream << fShaderFile.rdbuf();
// 现在可以关闭文件了
vShaderFile.close();
fShaderFile.close();
// 从字符串流中得到字符串
vertexCode = vShaderStream.str();
fragmentCode = fShaderStream.str();
}
catch (std::ifstream::failure& e)
{
std::cout << "ERROR::SHADER::FILE_NOT_SUCCESSFULLY_READ: " << e.what() << std::endl;
}
// 转换为C风格字符串
const char* vShaderCode = vertexCode.c_str();
const char* fShaderCode = fragmentCode.c_str();
// 2. 编译着色器
unsigned int vertex, fragment;
// 顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
checkCompileErrors(vertex, "VERTEX");
// 片段着色器
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fShaderCode, NULL);
glCompileShader(fragment);
checkCompileErrors(fragment, "FRAGMENT");
// 将着色器附加到着色器程序
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
checkCompileErrors(ID, "PROGRAM");
// 完成后着色器本身就不需要了,保留着色器程序即可
glDeleteShader(vertex);
glDeleteShader(fragment);
}
// 另外的成员函数
void use()
{
glUseProgram(ID);
}
// 设置uniform变量的值
void setBool(const std::string &name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void setInt(const std::string &name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void setFloat(const std::string &name, float value) const
{
glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}
以上就是我们自己的着色器类。有了它,我们就不需要在程序中手动声明源码字符串和创建着色器对象了。我们可以修改源码并使用着色器类来创建着色器对象。
// 从文件中读入、编译、链接着色器到着色器程序
// 此处的两个文件中保存的就是我们此前使用的两个着色器的源码
Shader ourShader("3_Vshader.vs", "3_Fshader.fs");
...
// 渲染循环中启用着色器程序
// 代替glUseProgram()
ourShader.use();
...
目前为止的源码
// 此处的两个头文件不能交换顺序
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
#include <learnopengl/shader_s.h>
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 // 顶部
};
unsigned int indices[] = {
// 0, 1, 3, // 第一个三角形由索引为0、1、3的三个顶点组成
// 1, 2, 3 // 第二个三角形由索引为1、2、3的三个顶点组成
0, 1, 2
};
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
// 当用户改变窗口大小时的回调函数
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);
}
int main()
{
glfwInit();
// 指定opengl版本
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 6);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
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);
// GLAD用于管理opengl的函数指针,在调用opengl的函数之前要先初始化GLAD,
// 此处将一个glfw的函数传入了GLAD中
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// 设置视口尺寸
glViewport(0, 0, 800, 600);
// 设置窗口尺寸的回调函数
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
unsigned int VBO;
// 创建缓冲
glGenBuffers(1, &VBO);
// 绑定缓冲
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 传入数据
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
unsigned int EBO;
glGenBuffers(1, &EBO);
// 将缓冲区对象绑定到目标并传入数据
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
Shader ourShader("3_Vshader.vs", "3_Fshader.fs");
// 创建顶点数组对象
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
// 设置顶点属性指针
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);
// 渲染循环
while (!glfwWindowShouldClose(window))
{
// 检测输入
processInput(window);
// 渲染指令(状态设置函数、状态使用函数等)
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
ourShader.use();
// 处理可变颜色
// float timeValue = glfwGetTime();
// float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
// int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
// glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
glBindVertexArray(VAO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
// 使用glDrawElements代替glDrawArrays
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
// 绘制后解绑VAO
glBindVertexArray(0);
glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
// 交换双缓冲
glfwSwapBuffers(window);
// 检查事件触发
glfwPollEvents();
}
// 结束
glfwTerminate();
return 0;
}