【OpenGL学习】Shader和Shader类的抽象
Shader
本节学习OpenGL中Shader的使用并将其抽象为类,简要介绍OpenGL所使用的着色器语言GLSL。
一、什么是Shader?
参考维基百科中对Shader的定义:着色器 - 维基百科,自由的百科全书 (wikipedia.org)
计算机图形学领域中,着色器(英语:shader)是一种计算机程序,原本用于进行图像的浓淡处理(计算图像中的光照、亮度、颜色等),但近来,它也被用于完成很多不同领域的工作,比如处理CG特效、进行与浓淡处理无关的视频后期处理、甚至用于一些与计算机图形学无关的其它领域。[1]
使用着色器在图形硬件上计算渲染效果有很高的自由度。尽管不是硬性要求,但目前大多数着色器是针对GPU开发的。GPU的可编程绘图管线已经全面取代传统的固定管线,可以使用着色器语言对其编程。构成最终图像的像素、顶点、纹理,它们的位置、色相、饱和度、亮度、对比度也都可以利用着色器中定义的算法进行动态调整。调用着色器的外部程序,也可以利用它向着色器提供的外部变量、纹理来修改这些着色器中的参数。
二、OpenGL中的Shader
在OpenGL中,我们使用GLSL语言进行Shader的编写,GLSL是为图形计算量身定制的,包含一些针对向量和矩阵操作的有用特性。
Shader的开头必须声明版本,接着添加输入和输出变量、uniform和main函数。每个着色器的入口点都是main函数,该函数中处理所有的输入变量,并将结果输出到输出变量中。
一般情况下,一个Shader中会包含以下内容:
#version version_number
in type in_variable_name;//输入变量
in type in_variable_name;
out type out_variable_name;//输出变量
uniform type uniform_name;//全局变量
int main()
{
// 处理输入并进行一些图形操作
...
// 输出处理过的结果到输出变量
out_variable_name = weird_stuff_we_processed;
}
对于顶点着色器,其对应的输入变量也就是in
后面的变量成为顶点属性(Vertex Attribute),可以声明的顶点属性是有上限的,一般由硬件决定,可以通过GL_MAX_VERTEX_ATTRIBS
来获取:
int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;
通常情况下它至少会返回16个。
二、GLSL中的数据类型
GLSL的数据类型可以来指定变量的种类。GLSL中包含默认基础数据类型:int
、float
、double
、uint
和bool
。此外GLSL也有两种容器类型,分别是向量(Vector)和矩阵(Matrix)。
2.1. 向量
类型 | 含义 |
---|---|
vec n | 包含n个float分量的默认向量 |
bvec n | 包含n个bool分量的向量 |
ivec n | 包含n个int分量的向量 |
uvec n | 包含n个unsigned int分量的向量 |
dvec n | 包含n个double分量的向量 |
一个向量的分量可以通过vec.x
获取,这里x
是指这个向量的第一个分量。也可以分别使用.x
、.y
、.z
和.w
来获取它们的第1、2、3、4个分量。GLSL允许对颜色使用rgba
,或是对纹理坐标使用stpq
访问相同的分量。
2.2. 向量的重组
重组方式如下面所示:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
要注意两边向量的长度必须相同。
同时也可以将一个向量作为参数传给另一个向量的构造函数来减少需求参数的数量:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
三、着色器的输入和输出
GLSL通过in
和 out
两个关键字来声明用于输入和输出的变量,只要变量能够匹配,数据就能够沿着管线传输。
对于顶点着色器,它的输入是从顶点数据中获取的。为了定义顶点数据的管理方式,使用location
来指定输入变量对应的顶点属性,例如在之前的小节当中 layout(location = 0)
指定了顶点的顶点坐标属性。
对于片元着色器,最终的输出一定是一个vec4的颜色变量,因为片元着色器的目标就是计算最终输出的颜色,如果没有输出颜色,OpenGL会默认把你的物体渲染为黑色或者白色(像上节中的在没有添加shader情况下渲染出的三角形为黑色的)。
如果想要在顶点着色器和片元着色器之间传输数据,要在顶点着色器中定义out
变量,在片元着色器中定义in
变量,变量的类型和名字必须完全相同,这样OpenGL运行的时候就会把两个变量链接到一起。
四、Uniform定义的全局变量
Uniform定义的变量可以在任何着色器中进行访问,并且在定义之后一直保持。
简单尝试一下,首先在 Fragment Shader 中我们声明一个uniform变量用来控制输出颜色
std::string FragmentSrc = R"(
#version 330 core
layout(location = 0) out vec4 Fragcolor;
in vec3 v_Position;
uniform vec4 u_Color;
void main()
{
Fragcolor = u_Color;
}
)";
在shader中定义了uniform变量还不够,因为还没有指定变量存储的数据,因此需要在程序中对其进行指定,在 Render Loop 中,首先创建了一个随时间变化的颜色值用于传给uniform变量:
//Set Uniform
float timeValue = glfwGetTime(); //获取运行时间
float colorValue = (sin(timeValue) / 2.f) + 0.5f;//颜色随时间变化,并归一化到 (0,1)之间
GLint location = glGetUniformLocation(ShaderProgram, "u_COlor");
glUseProgram(ShaderProgram);
glUniform4f(location, 0.0f, colorValue, 0.0f, 1.0f);
使用函数glUniform4f
来指定vec4
类型的uniform变量,指定之前,我们首先需要知道要指定的uniform变量的位置,所以使用函数 glGetUniformLocation
来获取之前定义的uniform变量的位置,第一个参数指定使用的着色器程序,第二个参数指定要获取的uniform变量的名称,注意:该名称必须和你定义的名称完全相同,否则会找不到对应的位置,返回-1。
更新一个uniform之前你必须先调用glUseProgram
,因为是在当前激活的着色器程序中设置uniform的。
对于
glUniform
函数的后缀,后缀指定了要设置的uniform变量的类型。一般有:
后缀 含义 f
函数需要一个float作为它的值 i
函数需要一个int作为它的值 ui
函数需要一个unsigned int作为它的值 3f
函数需要3个float作为它的值 fv
函数需要一个float向量/数组作为它的值
运行一下程序观察结果:
五、添加顶点属性
之前说过顶点数组中可以存放很多的顶点属性,例如顶点坐标,顶点的颜色,纹理坐标等,接下来我们尝试在顶点数组中添加颜色属性:
//Create an array of vertices
float vertices[3 * 6] =
{
//pos //Color
-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
};
这个顶点数组传入顶点缓冲区之后的布局应该是这样的:
因此现在一个顶点对应的数据有6个浮点数,那么OpenGL要怎么知道这些数据分别表示什么呢?
还记得函数glVertexAttribPointer
吗?这个函数就是用来指定内存中数据的布局的,所以我们添加代码如下:
// 顶点坐标属性
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);
在第一次调用glVertexAttribPointer
时,首先第一个参数指定该属性所在的位置,在内存中顶点坐标属性是第一个属性,所以这里我们设置为0,但是这次步长发生了变化,每个顶点的属性现在变为了6个float,所以步长设置为 6 * sizeof(float),最后一个参数指定偏移量,顶点坐标在内存中偏移量为0,设置为(void*)0,而第二次调用函数glVertexAttribPointer
,颜色属性在内存中是第二个属性,所以设置为1,步长仍然为 6,颜色属性在内存中的偏移量为 3 * sizeof(float),因为前面有三个float变量,不要忘记设置完之后要启用该顶点布局,也就是调用glEnableVertexAttribArray
。
之后还需要在shader中进行设置:
//---------------Vertex Shader----------------------
std::string VertexSrc = R"(
#version 330 core
layout(location = 0) in vec3 a_Position;
layout(location = 1) in vec3 a_Color;
out vec3 v_Position;
out vec3 v_Color;
void main()
{
v_Position = a_Position;
v_Color = a_Color;
gl_Position = vec4(a_Position, 1.0);
}
)";
//--------------------------------------------------
//--------------Fragment Shader---------------------
std::string FragmentSrc = R"(
#version 330 core
layout(location = 0) out vec4 Fragcolor;
in vec3 v_Position;
in vec3 v_Color;
uniform vec4 u_Color;
void main()
{
Fragcolor = vec4(v_Color, 1);
}
)";
//---------------------------------------------------
运行结果:
六、抽象着色器类
为了更方便的管理和使用shader,把shader抽象成一个类,并添加对应的方法,下面介绍我一般抽象的方式:
首先创建Shader类,添加头文件和源文件,并添加构造函数和析构函数:
#pragma once
#include <glad/glad.h>
#include <string>
class Shader
{
public:
Shader(const std::string& VertexShaderPath, const std::string FragmentShaderPath);
~Shader();
private:
GLint m_ShaderProgram;
};
回想一下我们之前使用shader时候都需要做什么,首先创建了两段字符串用于存储glsl代码,然后创建了两个shader对象,之后对两段字符串进行编译,确认了编译结果之后,和我们的着色器程序进行了链接并检查链接状态,在本节中,我们不再使用字符串硬编码glsl代码,而是将其存储在文件当中,这样在代码量大的时候不会影响观感,也便于管理,所以,我在Shader类中添加了如下接口:
//use Program
void Bind();
void UnBind();
private:
std::string ReadFile(const std::string& FilePath);
void Compile(const std::string& VertexShaderSrc, const std::string& FragmentShaderSrc);
其中Bind
函数和UnBind
函数是为了使用ShaderProgram
,因为每次对Shader进行操作的时候需要调用函数ShaderProgram
,ReadFile
函数是为了从文件中读取glsl代码到字符串中,Compile
函数对读取的glsl代码字符串进行编译。
函数定义如下:
Shader::Shader(const std::string& VertexShaderPath, const std::string FragmentShaderPath)
{
m_ShaderProgram = glCreateProgram();
std::string VertexShaderSrc = ReadFile(VertexShaderPath);
std::string FragmentShaderSrc = ReadFile(FragmentShaderPath);
Compile(VertexShaderSrc, FragmentShaderSrc);
}
Shader::~Shader()
{
glDeleteProgram(m_ShaderProgram);
}
void Shader::Bind()
{
glUseProgram(m_ShaderProgram);
}
void Shader::UnBind()
{
glUseProgram(0);
}
std::string Shader::ReadFile(const std::string& FilePath)
{
std::string ShaderSrc;
std::ifstream in(FilePath, std::ios::in, std::ios::binary);
if (in)
{
in.seekg(0, std::ios::end);
size_t size = in.tellg();
if (size != -1)
{
ShaderSrc.resize(size);
in.seekg(0, std::ios::beg);
in.read(&ShaderSrc[0], size);
in.close();
}
else
std::cout << "Could not read from file " << FilePath << std::endl;
}
else
std::cout << "Could not open the file! " << std::endl;
return ShaderSrc;
}
void Shader::Compile(const std::string& VertexShaderSrc, const std::string& FragmentShaderSrc)
{
//--------------Create and Compile Shader-----------------------
unsigned int VertexShader, FragmentShader;
// Create an empty vertex shader handle
VertexShader = glCreateShader(GL_VERTEX_SHADER);
// Send the vertex shader source code to GL
// Note that std::string's .c_str is NULL character terminated.
const GLchar* source = VertexShaderSrc.c_str();
glShaderSource(VertexShader, 1, &source, 0);
// Compile the vertex shader
glCompileShader(VertexShader);
GLint isCompiled = 0;
glGetShaderiv(VertexShader, GL_COMPILE_STATUS, &isCompiled);
if (isCompiled == GL_FALSE)
{
GLint maxLength = 0;
glGetShaderiv(VertexShader, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetShaderInfoLog(VertexShader, maxLength, &maxLength, &infoLog[0]);
// We don't need the shader anymore.
glDeleteShader(VertexShader);
// Use the infoLog as you see fit.
// In this simple program, we'll just leave
std::cout << infoLog.data() << std::endl;
std::cout << "VertexShader Compilation failed!" << std::endl;
return;
}
// Create an empty Fragment shader handle
FragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
// Send the Fragment shader source code to GL
// Note that std::string's .c_str is NULL character terminated.
source = FragmentShaderSrc.c_str();
glShaderSource(FragmentShader, 1, &source, 0);
// Compile the Fragment shader
glCompileShader(FragmentShader);
isCompiled = 0;
glGetShaderiv(FragmentShader, GL_COMPILE_STATUS, &isCompiled);
if (isCompiled == GL_FALSE)
{
GLint maxLength = 0;
glGetShaderiv(FragmentShader, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetShaderInfoLog(FragmentShader, maxLength, &maxLength, &infoLog[0]);
// We don't need the shader anymore.
glDeleteShader(FragmentShader);
// Use the infoLog as you see fit.
// In this simple program, we'll just leave
std::cout << infoLog.data() << std::endl;
std::cout << "FragmentShader Compilation failed!" << std::endl;
return;
}
glAttachShader(m_ShaderProgram, VertexShader);
glAttachShader(m_ShaderProgram, FragmentShader);
glLinkProgram(m_ShaderProgram);
// Note the different functions here: glGetProgram* instead of glGetShader*.
GLint isLinked = 0;
glGetProgramiv(m_ShaderProgram, GL_LINK_STATUS, (int*)&isLinked);
if (isLinked == GL_FALSE)
{
GLint maxLength = 0;
glGetProgramiv(m_ShaderProgram, GL_INFO_LOG_LENGTH, &maxLength);
// The maxLength includes the NULL character
std::vector<GLchar> infoLog(maxLength);
glGetProgramInfoLog(m_ShaderProgram, maxLength, &maxLength, &infoLog[0]);
// We don't need the program anymore.
glDeleteProgram(m_ShaderProgram);
// Don't leak shaders either.
glDeleteShader(VertexShader);
glDeleteShader(FragmentShader);
// Use the infoLog as you see fit.
// In this simple program, we'll just leave
std::cout << infoLog.data() << std::endl;
std::cout << "Shader link failed!" << std::endl;
return;
}
// Always detach shaders after a successful link.
glDetachShader(m_ShaderProgram, VertexShader);
glDetachShader(m_ShaderProgram, FragmentShader);
glDeleteShader(VertexShader);
glDeleteShader(FragmentShader);
}
此外,为了对全局变量Uniform进行设置,我们还需要添加类似如下的函数:
void SetFloat4(const std::string& name, glm::vec4);
该函数用于设置Uniform变量,定义如下:
void SetFloat4(const std::string& name, glm::vec4)
{
GLint location = glGetUniformLocation(m_ShaderProgram, name.c_str());
glUniform4f(location, values.x, values.y, values.z, values.w);
}
注意:这里使用了数学库glm,有关glm库的配置,可以参考下面的教程:(3条消息) OpenGL GLM 环境配置_Wonz的博客-CSDN博客_glm安装
后续用到其他uniform变量的时候可以按照上面的内容进行添加。
之后可以使用创建好的Shader类啦!把原来有关shader的代码删除,创建一个Shader对象:
//Shader
Shader TriangleShader("Asset/Shader/Triangle_VertexShader.glsl", "Asset/Shader/Triangle_FragmentShader.glsl");
在循环中指定我们之前设置的uniform全局变量:
//ShaderProgram Use
TriangleShader.Bind();
//Set Uniform
float timeValue = (float)glfwGetTime();
float colorValue = (sin(timeValue) / 2.f) + 0.5f;
glm::vec4 color(0.0f, colorValue, 0.0f, 1.0f);
TriangleShader.SetFloat4("u_Color", color);
运行观察结果,会看到之前的彩色三角形,也可以改变输出渐变绿色三角形,说明我们的类抽象成功了(将shader中的输出颜色设置为 u_Color )。