OpenGL从入门到放弃 #04 Shader


  上节我们学习了如何实现简单的顶点着色器和片段着色器,其中涉及到了着色器之间简单的输入输出和编写着色器源码的专用语言GLSL。但上节终究学的只是皮毛,这节将深入研究着色器,然后实现一个着色器类的封装。

GLSL


  GLSL是编写着色器的一种专为图形计算量身定制的语言,它里面包含一些针对向量和矩阵操作的特性。可以先看看一个着色器典型的规范:

#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;
}
  • #version version_number 每个着色器的开头总要声明版本
  • in 输入变量
  • out 输出变量
  • uniform 这个专门负责接收来自CPU的数据,后面详述。
  • main() 这是每个着色器的入口,执行着色器的相关操作,一般操作可以概括为把输入变量进行一定的处理后,将结构输出到输出变量中。
数据类型

  跟其他编程语言一样,GLSL也有自己的数据类型,且默认的基础数据类型与C语言一样:intfloatdoubleuintbool。另外GLSL有两种容器类型,分别是向量(Vector)和矩阵(Matrix)。
  GLSL的向量是一个可以包含1、2、3或4个分量的容器,而分量的类型可以是默认基础类型的任意一个:

类型 含义
vecn 包含n个float分量的默认向量
bvecn 包含n个bool分量的向量
ivecn 包含n个int分量的向量
uvecn 包含n个unsigned int分量的向量
dvecn 包含n个double分量的向量

  我们一般用vecn就足够了,float类型能满足大部分需求了。
  向量还有一个比较有趣的特性就是重组(Swizzling),它允许一个容器的分量数用自身的数据来扩充,具体请看:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;

  上述代码中,利用someVec自身数据按指定顺序填入了4分量的differentVec,其效果等同于vec4 differentVec = vec4(someVec.x, someVec.y, someVec.x, someVec.x)。这个顺序是可以按你需求任意组合的,只要别用当前容器不存在的分量就行。
  另外,我们还可以用向量当做参数传给其他向量的构造函数:

vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);

输入与输出


  每个着色器都应有自己的输入和输出变量,这样着色器间才能进行良好的数据传输。为此,GLSL定义了inout关键字来实现输入输出的需求。每个着色器都可以定义自己的输入输出变量,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。所谓的匹配就是变量的类型和名字要一致,等会会有一个例子。但是顶点着色器和片段着色器的输入输出有点特殊。顶点着色器特殊在它需要获取顶点数据的输入,为了能够准确接收顶点数据的输入,就要使用location这一元数据指定输入变量,它能指示顶点着色器去获取VAO的哪个顶点属性。除此之外还要提供一个额外的layout标识,这样才能链接到顶点数据。
  片段着色器特殊在它需要一个vec4向量来输出它处理完的颜色。
  现在举一个例子来看看,如何将输出变量与下一个着色器的输入变量匹配起来。我们打算让顶点着色器来决定片段着色器输出的颜色:

  • 顶点着色器
#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); // 把输出变量设置为暗红色
}
  • 片段着色器
#version 330 core
out vec4 FragColor;

in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

void main()
{
    FragColor = vertexColor;
}

  我们把顶点着色器输出的变量和片段着色器的输入变量设为同类型同名字,这样这两个变量就会联系起来,数据也会因此传递下去。片段着色器把来自顶点着色器的颜色数据直接输出,结果应该是能看到一个暗红色的三角形:


Uniform


  在刚才规范中我们看到一个之前未接触过的关键字Uniform,它是干嘛的呢?Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,相比于顶点数据这种麻烦的传输方式,Uniform显得更为直接,但也不能滥用。因为Uniform的变量都是全局变量(Global),这意味着uniform变量必须在每个着色器程序对象中都是独一无二的,且一直存在,直到被重置或更新。最重要的一点是它可以被着色器程序的任意着色器在任意阶段访问。我们可以利用uniform变量来传输颜色给片段着色器看看:

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量

void main()
{
    FragColor = ourColor;
}

  如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!

  现在我们可以为这个uniform变量添加数据,首先是要获得这个变量:

int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");

  GLEW提供了glGetUniformLocation()函数来获取某个指定uniform变量的索引值,它的第一个参数是指定uniform变量所在的着色器程序,第二个参数是指定该uniform变量的名字。
  在获得它的索引值之后就可以更新它的值了,至于传输值,就用到GLEW提供的另外一个函数void glUniform4f(GLint location, GLfloat v0, GLfloat v2, GLfloat v3)
  第一个参数指定uniform变量的索引值,然后4f就意味着设定uniform的4个float值。另外要注意的是虽然查询uniform变量不要求先使用着色器程序,但更新uniform是需要先使用着色器程序的:

float timeValue = glfwGetTime();
float redValue = sin(timeValue);
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexCorlorLocation, redValue, 0, 0, 1.0f);

  这次我们想传输一个跟随时间变化的颜色值给uniform,让其输出一个忽暗忽明的红色三角形。因为时间的变化是只有CPU知道的,GPU是不知道这个信息的,那么通过uniform把仅有CPU才知道的信息传给GPU是uniform的作用之一。
glfwGetTime():这个函数会在GLFW被初始化后返回以秒为单位的时间。
sin():对指定值进行正弦函数的变量。
  现在把这段代码放在渲染循环中,uniform变量就能每帧都被更新,实现颜色的变化 :

    while (!glfwWindowShouldClose(window))
    {
        //处理输入
        processInput(window);

        //渲染指令
        glClearColor(0.0f, 0, 0, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        float timeValue = glfwGetTime();
        float redValue = sin(timeValue);
        int vertexCorlorLocation = glGetUniformLocation(shaderProgram, "ourColor");
        glUseProgram(shaderProgram);
        glUniform4f(vertexCorlorLocation, redValue, 0, 0, 1.0f);
        glBindVertexArray(VAO[0]);
        //glDrawArrays(GL_TRIANGLES, 0, 3);
        glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
        
        glBindVertexArray(0);
        

        //接收输入,交换缓冲
        glfwSwapBuffers(window);
        glfwPollEvents();
    }
    
    glfwTerminate();
    return 0;

}

更多属性


  我们尝试往顶点数据中塞入更多属性,我们打算在每一个点指定一种不同的颜色:

float vertices[] = {
   // 位置              // 颜色
    0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f,    //顶部 蓝色
    0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,  //右下 红色
    -0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f  //左下 绿色
};

  于是乎,现在有了两种的顶点属性,分别是坐标和颜色,且也有了更多的数据要喂给顶点着色器,那么要重新修改顶点着色器的源码,让其用相应的变量去接收不同的顶点属性:

#version 330 core
layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0 
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main()
{
    gl_Position = vec4(aPos, 1.0f);
    ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

  为了让顶点着色器能够自己区分哪些数据是顶点,哪些数据是颜色,我们要重新修改一下链接顶点属性的函数,根据下图,我们能更好地作出修改:



  现在在每一个顶点中,包含6个数据分别是3个坐标和3个颜色值,每两个相邻顶点的同分量坐标(例如X分量坐标)相差3个坐标值长度(包括自身)和3个颜色值的长度,而颜色也同理相差3个坐标值长度和3个颜色值的长度,这个长度叫步长,通俗地讲就是,同一顶点属性的分量第二次出现距第一次出现的长度。这个步长就是链接顶点属性函数的第5个参数。另外要告知的还有就是第二个顶点属性颜色的总起始位置,根据图示就是在3个坐标值后面开始,在准确告知起始位置后,顶点着色器才能根据起始位置读取数据,并根据步长去读取下一组数据。

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 6, (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 6, (void*)(sizeof(float) * 3));
    glEnableVertexAttribArray(1);

   其中的0就是代表把数据读取到location = 0的变量中,1同理。3就是指定读取数据的数量,读取3个数值给到aPos,GL_FLOAT为读取的数据类型,GL_FALSE为不去标准化这些值,至于什么是标准化上节已经说得很清楚了,不再赘述,sizeof(float) * 6就是步长,(void*)(sizeof(float) * 3)为颜色数据的起始偏移量。现在可以来看看效果了:



  诶你说不是只给3个三角形顶点增加颜色值吗,现在怎么整个三角形都充满颜色了而且颜色还有了渐变的效果,没做这么高级的处理啊?这其实是片段着色器自动给你做的一种叫做片段插值处理(Fragment Interpolation)所呈现的效果。片段插值。这个插值其实跟我们之前在Unity里遇到的线性插值、球型插值中的插值同一个概念。就是在两个离散的点做连续函数的处理,使得这条曲线通过离散的点。虽然我们只给了3个顶点,但是片段着色器可不只是生成3个片段这么少,它会在两个顶点之形成更多的片段去填补在两个顶点间的空白位置,对于这些多出来的片段该是什么颜色,片段着色器会做颜色插值处理,就是考虑这个片段在两个顶点间的什么位置,给予相应权重的颜色加成。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝 + 70%绿。
  虽然这个三角形只有3个顶点和3个对应颜色,但却至少包含50000个片段。对这50000个片段进行颜色插值处理,那么所呈现的颜色就很丰富了。

着色器类


  目前我们的着色器的源码、编译、链接都在同一个main的cpp档下,要是我们又想写一个不同功能的着色器,又放在这个档下吗?那这个main档的代码将会非常冗杂且混乱,显然这是我们不想看到的。那么我们可以把这整个创建着色器的过程封装成一个类,它可以从硬盘读取源码文件,然后编译、链接它们。以此减少main档的代码数。
  我们打算把着色器类里的函数声明和变量声明放在头文件(Shader.h)里,而函数的实现和变量的初始化就放在源文件里(Shader.cpp)。
  在头文件里,首先添加必要的include:

#include 
#include 
#include 
#include 

#include

  fstrem是为了打开包含源码的文件。sstream是为了把文件里的内容一次性全部灌给一个字符串流中。string是为了把字符串流中的内容再灌给字符串中,最后把字符串转为字符数组(char*)。
  上面其实是把整个读取源码的过程给讲了一遍。为什么这么复杂?因为OpenGL只认识存放在字符数组(char*)里面的源码,放在字符串(string)里是不行的。而如果用字符数组当做接收来自文件的内容,是做不到一次性接收,它只能利用循环逐个字符接收。而sstrream提供了一次性接收的方法,就是把文件流里的内容读取到字符串流中。而字符串流要先转为字符串才能转回字符数组。
  在头文件里,我们定义一个Shader类,并声明一个构造函数,一个int变量存储着色器程序的ID,一个激活着色器程序的函数。构造函数接收两个着色器源码文件名的参数:

class Shader
{
public:
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    void use();
    
    unsigned int ID;

};

  现在重点就来到了这个构造函数上,在这个函数上,我们要实现着色器源码文件的打开、源码存储、着色器的创建、编译、链接和删除,还要有检错功能。
  首先是打开文件,先定义两个文件流变量然后设置好它们的异常检测,在打开失败(std::ifstream::failbit)亦或是文件损坏(std::ifstream::badbit)的情况下抛出异常:

#include"Shader.h"

using std::cout;
using std::endl;
using std::string;
using std::ifstream;
using std::stringstream;

Shader::Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
    ifstream vertexFile;
    ifstream fragmentFile;
    string vertexString;
    string fragmentString;
    vertexFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
    fragmentFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
    ...
}

  然后在try里打开文件,定义两个字符串流变量,把文件内容灌给字符串流,字符串流在转回字符串:

//接上
    try
    {
    // 打开文件
    vertexFile.open(vertexPath);
    fragmentFile.open(fragmentPath);
    //读取文件的缓冲内容到数据流中
    stringstream vertexStream;
    stringstream fragmentStream;

    vertexStream << vertexFile.rdbuf();
    fragmentStream << fragmentFile.rdbuf();
    // 关闭文件处理器
    vertexFile.close();
    fragmentFile.close();
    // 转换数据流到string

    vertexString = vertexStream.str();
    fragmentString = fragmentStream.str();
    }

关于这个rdbuf()函数我想在此做个笔记,我在一遍文章上找到比较好的关于它的解释:

  C++标准库封装了一个缓冲区类streambuf,以供输入输出流对象使用。每个标准C++输出输出流对象都包含一个指向streambuf的指针,用 户可以通过调用rdbuf()成员函数获得该指针,从而直接访问底层streambuf对象。因此,可以直接对底层缓冲区进行数据读写,从而跳过上层的格 式化输入输出操作。
  流对象通过调用rdbuf()获得了底层streambuf对象的指针,也就可以通过该指针调用streambuf支持你各种操作进行输入输出。
  输出流提供了一个重载版本operator<<,它以streambuf指针为参数,实现把streambuf对象中的所有字符输出到输出流 出中;输入流也提供了一个对应的operator>>重载版本,把输入流对象中的所有字符输入到streambuf对象中。

  然后用catch块捕捉错误,并给出错误提示:

    catch (const std::exception&)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }

  最后把字符串转成字符数组:

    // string转char*
    const char* vertexChar = vertexString.c_str();
    const char* fragmentChar = fragmentString.c_str();

  现在存储源码的字符数组有了,就可以来创建着色器了,其过程跟上节的其实差不多,这里不再解释,不过需要在检错那里做点笔记:

//创建并编译着色器,接上
    unsigned int vertex, fragment;
    int success;
    char infoLog[512];

    //顶点  
    vertex = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertex, 1, &vertexChar, 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;
    }

    //片段
    fragment = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragment, 1, &fragmentChar, NULL);
    glCompileShader(fragment);
    glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
    if (!success)
    {
        glGetShaderInfoLog(fragment, 512, NULL, infoLog);
        std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl;
    }

    //着色器程序
    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::LINK_FAILED\n" << infoLog << std::endl;
    }

    //删除着色器
    glDeleteShader(vertex);
    glDeleteShader(fragment);
}//至此构造函数结束

  对于着色器的编译检错,用的是glglGetShaderiv()函数和glGetShaderInfoLog()函数:

glglGetShaderiv(GLunit shader, GLenum pname, GLint *param):这个函数允许开发者查询一个着色器某个特定项目(Specifies the object parameter)的信息。第一个参数指定要查询的着色器;第二参数指定要查询的项目的枚举值,有GL_DELETE_STATUSGL_COMPILE_STATUSGL_INFO_LOG_LENGTHGL_SHADER_SOURCE_LENGTH,这里我们要查询的是着色器编译的状态,所以是GL_COMPILE_STATUS;第三个参数是返回请求的参数结果值。

glGetShaderInfoLog(GLuint shader, GLsizei maxLength, GLLLsizei *length, GLchar *infoLog):这个函数是与glGetShaderiv函数配合使用的,它会返回在查询过程中所得到的的日志信息(出错才有信息)。第一个函数指定着色器;第二个函数指定返回信息的最大长度;第三个函数返回信息的实际长度;第四个参数用来接收日志内容。
  对于着色器程序的链接检错,用的是glGetProgramiv()函数和glGetProgramInfoLog()函数,跟上者大体相似,需要解释的就是查询的项目是GL_LINK_STATUS表示查询的是着色器程序的链接状态。
  至此整个着色器类的构造函数就完工了。剩下的激活函数use()其实很简单,就是glUseProgram()的调用而已:

void Shader::use()
{
    glUseProgram(ID);
}

  至此整个着色器类就弄好了,创建两个着色器源码文件来测试一下:


//FragmentShaderSource.txt
#version 330 core
out vec4 FragColor;  
in vec3 ourColor;

void main()
{
    FragColor = vec4(ourColor, 1.0f);
}
//VertexShaderSource.txt
#version 330 core
layout (location = 0) in vec3 aPos;   // 位置变量的属性位置值为 0 
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main()
{
    gl_Position = vec4(aPos, 1.0f);
    ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}

  然后在main()函数里创建一个Shader类对象,把刚创建的两个源码文件名喂给构造函数:

    Shader shader("VertexShaderSource.txt", "FragmentShaderSource.txt");

  剩下的就是在渲染循环中调用该对象的激活函数了:

    while (!glfwWindowShouldClose(window))
    {
        //处理输入
        processInput(window);

        //渲染指令
        glClearColor(0.0f, 0, 0, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);


        shader.use();
        glBindVertexArray(VAO[0]);
        glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, 0);
        
        glBindVertexArray(0);
        

        //接收输入,交换缓冲
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

  现在运行看看结果如何:

  能看这个结果说明我们的着色器类能够正常工作了。其实关于着色器类还有uniform的部分,这个部分就留到下一节练习来实现。

你可能感兴趣的:(OpenGL从入门到放弃 #04 Shader)