【Modern OpenGL】Shader

说明:跟着learnopengl的内容学习,不是纯翻译,只是自己整理记录。
强烈推荐原文,无论是内容还是排版。 原文链接
本文地址: http://blog.csdn.net/aganlengzi/article/details/50420974

Shaders

正如在上一篇教程中提到的,shader是在GPU中运行的小程序。如上一个教程中实现的最简单的vertex shader和fragment shader,一个shader基本上负责图形渲染流水线中的一个阶段的功能。从根本上来说,shader就是将输入转化成输出的操作。而且,它们之间是独立的,除了以输入和输出方式外,他们之间不允许进行通信。

上一篇教程中我们仅仅是知道了关于shader最基本的写法和用法。在本篇教程中我们将对shader进一步讲解,特别是GLSL(OpenGL Shading Language,简称GLSL)语言。

GLSL

Shader使用与C类似GLSL语言来书写的。GLSL是为图形处理量身定做的语言,它包含很多针对向量或者矩阵操作的特性。

Shader一般以版本声明开始,接着声明输入和输出变量。uniform变量(先理解成一种全局变量,后面会讲到)和主函数(main函数)。每一个shader的入点都是main函数,在main函数中,我们对输入数据进行处理,然后将处理结果写到输出数据中。

一个shader的典型结构如下所示:

//版本声明
#version version_number
//输入声明  
in type in_variable_name;
in type in_variable_name;
//输出声明
out type out_variable_name;
//uniforms
uniform type uniform_name;
//主函数
void main()
{
  // Process input(s) and do some weird graphics stuff
  ...
  // Output processed stuff to output variable
  out_variable_name = weird_stuff_we_processed;
}

当我们说到具体的shader的时候,比如说vertex shader,每一个输入变量又叫做顶点属性(vertex attribute)。对输入的顶点属性数量有一个上界,是由硬件决定的。OpenGL保证知道有16个4分量的顶点属性可用,但是某些硬件可能会支持更多,可以通过查询GL_MAX_VERTEX_ATTRIBS来获得自己机器上支持的数量:

GLint nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl; 

一般情况下返回值会大于等于16,无特殊要求是够用了。我的平台上也是输出16。

Types

GLSL有和其他编程语言类似的数据类型用于指定变量的种类。在GLSL中,int, float, double, uint 和 bool是和C一样的基本数据类型,还有两种容器类型的变量,我们在后面会经常用到,它们分别是vector(向量)和matrice(矩阵)。我们将在后面的教程中讨论矩阵。

向量 Vectors

GLSL中的vector是含有1,2,3,或者4个基本数据类型分量的容器。用如下的形式来声明向量(其中n代表向量中分量的个数):

  • vecn: 默认情况下分量数据类型是float。
  • bvecn: bool类型的向量。
  • ivecn: 整型类型的向量。
  • uvecn: 无符号整型类型的向量。
  • dvecn: 双精度类型的向量。

在大多数情况下我们使用默认情况下的vecn就够了,因为浮点类型的分量已经够我们大部分的使用了。

向量的分量可以通过vec.x形式访问。可以分别使用.x, .y, .z 和 .w来访问向量的第一、二、三、和四个分量。GLSL还可以使用rgba来访问颜色向量,或者使用stpq来访问纹理坐标,他们也能访问相同的分量值。

GLSL中的向量是十分灵活的,它允许一些有趣的操作——它支持以下类似的语法:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

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

总体来说,向量是一种十分灵活的数据类型,它可以用来声明各种输入和输出。让我们在教程中根据实例仔细体会吧。

输入和输出

从Shader自身来说,他们是小的独立程序,但是从整体来说,他们是整个图形渲染流水的组成部分,这也是为什么我们要让它们有输入和输出。GLSL为此专门定义了in和out关键字。每个shader都可以定义用这两个关键字来指明自身的输入和输出数据,当其它的shader中的输入和输出数据类型能够相匹配,那么这两个shader就可以连接起来,相应的数据流就可以在连接起来的shader之间流通。我们之前定义的vertex shader和fragment shader明显是不能够进行连接的,因为它们的输入输出接口是匹配不上的。

Vertex shader应该支持不同种类的输入,否则它就是低效的。因为它是图形渲染流水线的最开始的顶点数据输入,而输入的数据的类型也是多种多样的。为了定义顶点数据的组织方式,我们通过location标定输入的变量,这样我们可以在CPU中来配置顶点属性。我们在上一个教程中的vertex shader中的layout (location = 0)就是这种用法。所以vertex shader需要为其输入额外规定布局(layout),这样就可以和具体的顶点数据联系起来。

实际上事先指定输入数据布局的方法,也就是通过类似layout (location = 0)的声明完成,可以通过在OpenGL中调用glGetAttribLocation的方式取代。但是这种方式相当于把shader和OpenGL的工作分开来。

fragment shader应该输出的是颜色值,应该是一个vec4类型的向量。因为片段处理器本质上决定了屏幕上显示的每个像素的颜色值(当然有可能会被后面的混合器改变),所以如果没有指定或者错误指定输出颜色值,那么OpenGL渲染得到的可能是错误的。

所以,如果我们想要在A shader和B shader之间传递数据,比如说从A传到B,那么至少应该在A中定义输出变量,在B中定义输入变量,而且要求这个输入和输出变量的数据类型和名称必须一致。这样OpenGL才会在图形渲染流水线中将这两个shader连接起来以完成数据的传递。为了更好的理解上面说的这些,下面修改上个教程创建的vertex shader和fragment shader来进行理解:

Vertex shader

#version 330 core
layout (location = 0) in vec3 position; // The position variable has attribute position 0 

out vec4 vertexColor; // Specify a color output to the fragment shader

void main() 
{
    gl_Position = vec4(position, 1.0); // See how we directly give a vec3 to vec4's constructor
    vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // Set the output variable to a dark-red color
} 

Fragment shader

#version 330 core
in vec4 vertexColor; // The input variable from the vertex shader (same name and same type)

out vec4 color;

void main() 
{
    color = vertexColor;
} 
如上面代码所示:我们在vertex shader中声明了一个vec4类型的变量vertexColor作为其输出;在fragment shader中我们也声明了一个同名同类型的变量,但是作为其输入。所以这两个变量实际上就将这两个shader“连接”起来了——vertex shader可以利用vertexColor变量给fragment shader传递颜色值。在例子中,我们在vertex shader中给vertexColor赋值为一个深红色的颜色,fragment shader中用这个颜色为其输出的颜色值赋值,那么我们也应该得到最终的图形的渲染颜色是深红色,实际上也是这样,我得到的结果是:

哈哈,我们成功将一个颜色值从vertex shader传递到fragment shader中!让我们再尝试一下更有趣的:从我们的程序中传递一个颜色值给fragment shader,这需要用到我们在开头提到的uniform。

Uniforms

与顶点属性类似,uniform是从在CPU中运行的程序向在GPU中运行的shader的另一种方式,但是二者却有很大的不同。首先,uniform类型的变量是全局的,这就意味着:首先,每个shader都必须有一个唯一命名的uniform变量,并且在任何shader(不需要连接在一起)中都能够访问其它shader中的uniform变量;其次,uniform变量的值一直保持不变,直到被重置或者更新才会改变。

在GLSL中声明一个uniform只需要在变量声明的时候加上一个关键字uniform。在此之后我们就可以使用这个uniform变量。接下来,让我们尝试一下是否可以使用uniform来设置fragment shader的数据结果值。原理就是,我们在fragment shader中声明一个全局变量,并将fragment的最后输出结果赋值为这个uniform值,然后我们在OpenGL程序中对这个uniform变量进行修改,然后看效果,首先是fragment shader:

#version 330 core
out vec4 color;

uniform vec4 ourColor; // We set this variable in the OpenGL code.

void main()
{
    color = ourColor;
}   

如你所见,在这个fragment shader中,我们定义了一个vec4类型的变量ourcolor,前面的uniform关键字标明它是一个uniform类型的变量。然后,我们将fragment shader的输出值color赋值为ourcolor。实际上,因为uniform类型的变量是全局变量,我们可以在任何的shader中定义,在任何的shader中使用已定义多的uniform变量。

如果你定义了一个uniform类型的变量,但是在GLSL程序中并没有使用过,那么,编译器就会在编译的时候将这个变量给去掉。这可能会造成一些奇怪的错误(比如说你在OpenGL中对这个uniform赋值),我们应该记住这一点。

上面的uniform变量当前是空的,因为我们还没有对它进行任何的赋值操作。下面我们就来对它进行赋值。首先,我们要找到这个uniform变量的索引/位置,然后我们可以对它进行值的更新。我们不想仅仅传递单一的颜色给fragment shader,我们让这个颜色值随着时间改变,代码如下:

GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

首先,我们通过glfwGetTime函数取得运行时以秒为单位的时间值,然后我们设置绿色分量的值在0.0-1.0之间随着时间变化。再然后,我们利用glGetUniformLocation函数取得我们在fragment shader中声明的uniform变量的索引/位置。最后利用glUniform4f函数对这个位置的值进行更新。

需要注意的是,在调用glGetUniformLocation函数时,我们需要传递我们组装的渲染程序对象名称,在我们的例子中是”shaderProgram”,它指明了在哪儿查找,同时需要给出我们想要查找哪个uniform,即给出我们要查找的uniform名称。如果这个函数返回-1,那么表示没有找到。成功找到后,我们最后通过glUniform4f来根据找到的位置设置这个uniform的值。

需要注意的是,在设置一个渲染程序对象中的uniform变量值的时候,需要用glUseProgram函数来显示表明我们要修改的渲染程序对象,在本例中,即shaderProgram。

因为OpenGL的核心是一个C库,所以它没有提供类型重载的功能。所以,OpenGL为每种需要的函数都定义了一个函数,glUniform是一个很好的例子。glUniform函数需要在一个指定需要设置数据类型的后缀,如本例中的4f,表明这个函数有四个float类型的参数。一些其它可能的后缀如下:

f: 函数有1个float类型的参数
i: 函数有1个int类型的参数
ui: 函数有1个unsigned int类型的参数
3f: 函数有3个float类型的参数
fv: 函数有1个float类型分量的vector参数

所以,每当需要重载的时候,只需要在后面添加相应的后缀就可以了。

现在我们已经知道怎样设置uniform类型变量的值了,我们可以用它们来进行渲染了。如果我们想让颜色是渐变的,那么我们可以在每次游戏循环(每帧)中对uniform进行更新,否则,如果我们只调用一次,那么颜色值也就只有一种。我们在下面的程序中采用前一种方式:

while(!glfwWindowShouldClose(window))
{
    // Check and call events
    glfwPollEvents();

    // Render
    // Clear the colorbuffer
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // Be sure to activate the shader
    glUseProgram(shaderProgram);

    // Update the uniform color
    GLfloat timeValue = glfwGetTime();
    GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
    GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
    glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

    // Now draw the triangle
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glBindVertexArray(0);
}

上面的代码看上去是比较简单的,只是在原有基础上添加了uniform值的更新,如果正确的话,我们应该能够得到所绘制的图形颜色渐变的结果。目前为止的代码在这儿。

正如你看到的,uniform是一个很好的工具,它可以帮助我们在每次渲染迭代中设置属性或者在程序和shader之间传递数据。但是,如果我,如果我们想要设置每个顶点的颜色呢?如果要使用uniform的方式,那需要定义和点的数量相同的uniform变量。这是复杂和不可接受的。一个更好的解决方法是在顶点属性中包含更多的值——也就是更多的属性值。

更多的顶点属性值

我们在前面的教程中已经知道怎样填充一个VBO,怎样配置一个顶点属性指针和怎样存储在VAO中。现在,我们想要为每个顶点数据添加颜色值。具体来说,我们想为每个顶点数据添加3个float类型数据来指定颜色值,这三个数值分别代表rgb分量。

GLfloat vertices[] = {
    // Positions         // Colors
     0.5f, -0.5f, 0.0f,  1.0f, 0.0f, 0.0f,   // Bottom Right
    -0.5f, -0.5f, 0.0f,  0.0f, 1.0f, 0.0f,   // Bottom Left
     0.0f,  0.5f, 0.0f,  0.0f, 0.0f, 1.0f    // Top 
};  

因为目前我们有更多的数据要发送到顶点渲染程序,那么有必要对顶点渲染程序进行调整,使其支持我们颜色值的输入,我们又定义了一个vec3类型的变量color,指定布局中的位置为1,如下所示:

#version 330 core
layout (location = 0) in vec3 position; // The position variable has attribute position 0
layout (location = 1) in vec3 color;    // The color variable has attribute position 1

out vec3 ourColor; // Output a color to the fragment shader

void main()
{
    gl_Position = vec4(position, 1.0);
    ourColor = color; // Set ourColor to the input color we got from the vertex data
}  

有了每个顶点的颜色值,我们不再需要通过uniform类型的颜色值对顶点颜色进行设置,所以我们也要修改相应的fragment shader,如下所示:

#version 330 core 
in vec3 ourColor;
out vec4 color;

void main()
{
    color = vec4(ourColor, 1.0f);
} 

因为我们在顶点属性中添加了数值,而且更新了VBO的内存,我们需要重新配置顶点属性指针。更新后的数据在VBO内存中的组织方式是这样的:
【Modern OpenGL】Shader_第1张图片

根据数据的这个布局方式,我们可以利用glVertexAttribPointer函数设置OpenGL解释这些数据的方式。

// Position attribute 
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// Color attribute
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1); 
glVertexAttribPointer函数的前几个参数相对简单,在前一个教程中已经说得比较明确。在这里,我们对顶点属性中的位置属性的配置和前面教程中的基本一致,只是第五个参数的设置上稍有不同,因为这个参数代表的是两个顶点数据之间的间隙,而我们新创建的顶点数组中每个顶点属性的数据大小为6个GLfloat类型长度,所以这里设定的是6 * sizeof(GLfloat)。另外在对顶点属性中的颜色属性进行设置的时候,我们指定的location是1(第一个参数),在最后一个参数中,我们设定的偏移量是3,因为每个顶点数据中,颜色数据是在顶点数据开始偏移3个GLfloat类型数据的位置。 好的,运行上面的程序,我们应该能够得到下面的结果,我的是这样:

代码在这儿.

图像显示的效果可能和你想的不是太一样,因为我们只是设定了三角形的三个颜色为红绿蓝,为什么感觉整个调色板的颜色都显示出来了呢?这是由在片段处理器中的一种叫做片段插值的技术造成的。在渲染一个三角形的时候,光栅化阶段通常会产生比最初设置的多得多的片段(一个片段就是一个要显示在屏幕上的点的所有的信息)。光栅化程序在此基础上根据它们在三角形中的相对位置决定每个片段在屏幕中的位置。

根据这些位置,它对片段处理器输出的颜色值进行线性插值的操作。比如说,我们有一条线,其上端点是是绿色的,下端点是蓝色的。如果片段处理程序作用在这条线的靠近绿色30%的地方,那么这个点的颜色值就是30%蓝色和70%绿色的线性组合。

这就是我们的三角形呈现出线性变化的多种颜色的原理。虽然我们只设置了三个顶点的三种颜色,但是这个三角形中应该差不多包含了50,000个像素点,对应者50,000个片段。没有被我们设置颜色的片段就会被通过上述由点的位置决定的线性颜色插值处理,并最终由于颜色的混合得到我们看到的三角形的样子。

关于shader的写法,编译和使用上次教程就已经说到,本次教程又讲了shader中的具体的数据结构,输入输出变量的设置,uniform变量的使用和改变要输入的顶点属性等等,下面作者还想要更深一步,讲解shader类的使用。

我们自己的shader类

上述过程中,书写,编译和管理shader是比较繁杂的。我们想通过创建一个shader类使得这整个过程变得更容易一些。shader类可以从磁盘中读取shader源码、编译和装配他们、处理错误。这也能够让我们对我们到目前学到的只是进行一个有益的抽象,即用类来实现和管理shader。

我们将创建shader类的所有内容放在一个头文件中,主要是为了学习和移植方面的考虑。让我们首先来包含必要的头文件和定义结构体数据类型吧:

#ifndef SHADER_H
#define SHADER_H

#include 
#include 
#include 
#include 

#include ; // Include glew to get all the required OpenGL headers

class Shader
{
public:
    // The program ID
    GLuint Program;
    // Constructor reads and builds the shader
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    // Use the program
    void Use();
};

#endif 

在文件的一开头,我们利用两行预处理指令来保证这个头文件只会在我们的程序中包含一次,即使在很多源文件中都有定义。这样能够避免链接时候的重复定义错误。

这个shader类保存渲染程序对象的ID号,它的构造函数需要顶点处理程序和片段处理程序的路径作为参数。它们可以被简单存储为字符文件。另外,我们额外增加了一个use函数,虽然琐碎,但是能够帮助我们减少我们的工作量。

从文件读入shader程序

我们将在其构造函数中使用C++文件流来从文件中将shader程序的内容读入到几个字符串对象中:

Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
    // 1. Retrieve the vertex/fragment source code from filePath
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // ensures ifstream objects can throw exceptions:
    vShaderFile.exceptions(std::ifstream::badbit);
    fShaderFile.exceptions(std::ifstream::badbit);
    try 
    {
        // Open files
        vShaderFile.open(vertexPath);
        fShaderFile.open(fragmentPath);
        std::stringstream vShaderStream, fShaderStream;
        // Read file's buffer contents into streams
        vShaderStream << vShaderFile.rdbuf();
        fShaderStream << fShaderFile.rdbuf();       
        // close file handlers
        vShaderFile.close();
        fShaderFile.close();
        // Convert stream into GLchar array
        vertexCode = vShaderStream.str();
        fragmentCode = fShaderStream.str();     
    }
    catch(std::ifstream::failure e)
    {
        std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
    }
    const GLchar* vShaderCode = vertexCode.c_str();
    const GLchar* fShaderCode = fragmentCode.c_str();
    [...]

接下来我们需要编译和装配这些shaders。需要注意的是我们需要处理编译出错的情况。如果出错的话,我们打印出编译时的错误方便我们的调试(你早晚会用到的):

// 2. Compile shaders
GLuint vertex, fragment;
GLint success;
GLchar infoLog[512];

// Vertex Shader
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vShaderCode, NULL);
glCompileShader(vertex);
// Print compile errors if any
glGetShaderiv(vertex, GL_COMPILE_STATUS, &success);
if(!success)
{ 
    glGetShaderInfoLog(vertex, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
};

// Similiar for Fragment Shader
[...] 

// Shader Program
this->Program = glCreateProgram();
glAttachShader(this->Program, vertex);
glAttachShader(this->Program, fragment);
glLinkProgram(this->Program);
// Print linking errors if any
glGetProgramiv(this->Program, GL_LINK_STATUS, &success);
if(!success)
{ 
    glGetProgramInfoLog(this->Program, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << infoLog << std::endl;
}

// Delete the shaders as they're linked into our program now and no longer necessery
glDeleteShader(vertex);
glDeleteShader(fragment); 

最后我们实现use函数,它只负责对glUseProgram的调用:

void Use() { glUseProgram(this->Program); }  

这样就完成了我们自己的shader类的创建。使用这个类也十分简便,我们只要生成一个shader类的对象,然后使用这个对象就好了:

Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.frag");
... 
while(...)
{
    ourShader.Use();
    glUniform1f(glGetUniformLocation(ourShader.Program, "someUniform"), 1.0f);
    DrawStuff(); 
}

上面代码中,假设我们将两个shader分别存储在shader.vs和shader.frag中。命名什么的都是无所谓的,只要存储的是字符文件保证读出来的是字符创就可以了。
本节最终代码。

你可能感兴趣的:(OpenGL)