OpenGL学习笔记5-Shaders

着色器

正如在Hello Triangle一章中提到的,着色器是基于GPU的小程序。这些程序针对图形管道的每个特定部分运行。在基本意义上,着色器只不过是把输入转换成输出的程序。着色器也是非常孤立的程序,因为它们不允许彼此通信;他们唯一的交流是通过他们的输入和输出。

在前一章中,我们简要地接触了着色器的表面以及如何正确地使用它们。现在我们将以更一般的方式解释着色器,特别是OpenGL着色语言。

GLSL

着色器是用类c语言GLSL编写的。GLSL是为与图形一起使用而定制的,包含了专门针对向量和矩阵操作的有用特性。

着色器总是以版本声明开始,然后是输入和输出变量的列表,Uniforms和它的主要功能。每个着色器的入口点都在它的主要函数处,在这里我们处理任何输入变量并在输出变量中输出结果。如果你不知道什么是Uniforms,别担心,我们很快就会知道。

一个着色器通常有以下结构:

#version version_number
in type in_variable_name;
in type in_variable_name;

out type out_variable_name;
  
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;
}

当我们特别谈论顶点着色器时,每个输入变量也被称为顶点属性。我们可以声明的顶点属性的最大数量受硬件的限制。OpenGL保证至少有16个4组件的顶点属性可用,但一些硬件可能允许更多,你可以通过查询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拥有我们从C语言中知道的大多数默认基本类型:int、float、double、uint和bool。GLSL还提供了两种容器类型,即向量和矩阵,我们将大量使用它们。我们将在后面的章节中讨论矩阵。

向量

GLSL中的向量是刚才提到的任何基本类型的1、2、3或4组件容器。可以采用以下形式(n表示组件的数量):

  • vecn: n个浮点数的默认向量。
  • bvecn: n个布尔值的向量。
  • ivecn:一个n个整数的向量.
  • uvecn: 一个n个无符号整数的向量.
  • dvecn: a vector of n double components.

大多数时候,我们将使用基本的vecn,因为float对于我们的大多数目的来说已经足够了。

向量的分量可以通过向量-x访问,其中x是向量的第一个分量。您可以使用.x、.y、.z和.w分别访问它们的第一个、第二个、第三个和第四个组件。GLSL还允许您使用rgba用于颜色或stpq用于纹理坐标,访问相同的组件。

向量数据类型允许一些有趣而灵活的组件选择,称为swizzling。Swizzling允许我们使用这样的语法:

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

你可以使用多达4个字母的任何组合来创建一个新的矢量(相同类型),只要原始矢量有这些组件;例如,不允许访问vec2的.z组件。我们也可以将向量作为参数传递给不同的向量构造函数调用,减少所需参数的数量:

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

因此,向量是一种灵活的数据类型,可以用于各种输入和输出。在整本书中,你会看到很多关于我们如何创造性地管理向量的例子。

进进出出(Ins and outs)

着色器本身是很好的小程序,但它们是整体的一部分,因此我们想要在每个着色器上有输入和输出,这样我们就可以移动东西。为此,GLSL专门定义了in和out关键字。每个着色器都可以使用这些关键字指定输入和输出,当输出变量与下一个着色器阶段的输入变量匹配时,它们就会被传递。顶点和片段着色器有一点不同。

顶点着色器应该接受某种形式的输入,否则它将是相当无效的。顶点着色器的输入不同,因为它直接从顶点数据接收输入。为了定义顶点数据的组织方式,我们用位置元数据指定输入变量,这样我们就可以在CPU上配置顶点属性。我们在前面的章节中已经看到了布局(location = 0),因此顶点着色器需要一个额外的布局说明,以便我们可以将它的输入与顶点数据链接起来。

也可以省略布局(location = 0)说明符,并通过glGetAttribLocation查询OpenGL代码中的属性位置,但我更喜欢在顶点着色器中设置它们。它更容易理解,并节省了您(和OpenGL)的一些工作。

另一个例外是片段着色器需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终的输出颜色。如果你没有在你的片段着色器中指定一个输出颜色,那些片段的颜色缓冲输出将是未定义的(这通常意味着OpenGL将渲染它们要么是黑色要么是白色)。

如果我们想从一个着色器发送数据到另一个着色器,我们必须在发送着色器中声明一个输出,在接收着色器中声明一个类似的输入。当类型和名字在两边相等时,OpenGL将这些变量链接在一起,然后就有可能在着色器之间发送数据(这是在链接程序对象时完成的)。为了向你展示这是如何在实践中工作的,我们将改变前一章的着色器,让顶点着色器决定片段着色器的颜色。

Vertex shader


#version 330 core
layout (location = 0) in vec3 aPos; // the position variable has attribute position 0
  
out vec4 vertexColor; // specify a color output to the fragment shader

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

Fragment shader


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

void main()
{
    FragColor = vertexColor;
} 

你可以看到我们在顶点着色器中声明了一个vertexColor变量作为vec4输出,我们在片段着色器中声明了一个类似的vertexColor输入。因为它们都有相同的类型和名称,在片段着色器的vertexColor链接到顶点着色器的vertexColor。因为我们在顶点着色器中将颜色设置为暗红色,所以产生的片段也应该是暗红色的。输出如下图所示:

现在!我们只是设法从顶点着色器发送一个值到片段着色器。让我们添加一点香料,看看我们是否可以从我们的应用程序发送一个颜色到片段着色器!

Uniforms

Uniforms是另一种方式,从我们的应用程序上的CPU到GPU的着色器传递数据。然而,Uniforms与顶点属性稍有不同。首先,制服是全局性的。全局的,意思是一个统一的变量对每个着色程序对象是唯一的,并且可以在着色程序的任何阶段从任何着色程序访问。其次,无论您将Uniforms值设置为什么,Uniforms将保持它们的值,直到它们被重置或更新。

要在GLSL中声明一个统一,我们只需在一个具有类型和名称的着色器中添加统一关键字。从那时起,我们可以在着色器中使用新声明的Uniforms。让我们看看这次是否可以通过Uniforms来设置三角形的颜色:


#version 330 core
out vec4 FragColor;
  
uniform vec4 ourColor; // we set this variable in the OpenGL code.

void main()
{
    FragColor = ourColor;
}   

我们在片段着色器中声明了一个统一的vec4 ourColor,并将片段的输出颜色设置为这个统一值的内容。因为Uniforms是全局变量,我们可以在任何我们想要的着色器阶段定义它们,所以不需要再次通过顶点着色器来获得一些东西到片段着色器。我们没有在顶点着色器中使用这个Uniforms,所以没有必要在那里定义它。

如果你声明了一个在你的GLSL代码中没有使用的Uniforms,编译器会悄悄地从编译的版本中删除变量,这是一些令人沮丧的错误的原因;记住这一点!

Uniforms目前是空的;我们还没有给Uniforms添加任何数据,让我们试一下。我们首先需要找到着色器中统一属性的索引/位置。一旦我们有了统一的索引/位置,我们就可以更新它的值。而不是传递一个单一的颜色到碎片着色器,让我们随着时间逐渐改变颜色:


float timeValue = glfwGetTime();
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

首先,我们通过glfwGetTime()检索以秒为单位的运行时间。然后我们使用sin函数在0.0 - 1.0范围内改变颜色,并将结果存储为greenValue。

然后使用glGetUniformLocation查询ourColor统一的位置。我们向查询函数提供着色程序和uniform的名称(我们想要从中检索位置)。如果glGetUniformLocation返回-1,它将无法找到该位置。最后,我们可以使用glUniform4f函数设置uniform 值。注意,找到uniform 的位置不需要你使用着色器程序,但更新一个uniform 确实需要你首先使用程序(通过调用glUseProgram),因为它设置了当前活动的着色器程序的uniform。

因为OpenGL是在它的核心C库中,它不支持函数重载,所以只要一个函数可以被不同类型调用,OpenGL就会为每种类型定义新的函数;glUniform就是一个很好的例子。对于你想要设置的制服类型,这个函数需要一个特定的后缀。

函数需要一个浮点数作为它的值。

  • f: the function expects a float as its value.
  • i: the function expects an int as its value.
  • ui: the function expects an unsigned int as its value.
  • 3f: the function expects 3 floats as its value.
  • fv: the function expects a float vector/array as its value.

每当您想配置OpenGL的选项时,只需选择与您的类型对应的重载函数。在我们的例子中,我们希望分别设置4个统一浮动,这样我们就可以通过glUniform4f传递数据(注意,我们也可以使用fv版本)。

现在我们知道了如何设置Uniform变量的值,我们可以使用它们来进行渲染。如果我们想让颜色逐渐变化,我们需要在每一帧中更新这个Uniform的颜色,否则如果我们只设置一次,三角形就会保持单一的纯色。因此,我们计算greenValue,更新统一的渲染迭代:


while(!glfwWindowShouldClose(window))
{
    // input
    processInput(window);

    // 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
    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);

    // now render the triangle
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
  
    // swap buffers and poll IO events
    glfwSwapBuffers(window);
    glfwPollEvents();
}

该代码是对前面代码的相对简单的修改。这一次,我们在绘制三角形之前在每一帧中更新一个Uniform的值。如果你正确地更新了制服,你应该会看到你的三角形的颜色逐渐从绿色变成黑色,然后再变成绿色。

如果您卡住了,请在这里here 查看源代码。

正如你所看到的,Uniform是一个有用的工具来设置属性,可以改变每一帧,或者在你的应用程序和着色器之间交换数据,但是如果我们想为每个顶点设置颜色呢?在那种情况下,我们必须声明和顶点一样多的Uniform。一个更好的解决方案是在顶点属性中加入更多的数据,这就是我们现在要做的。

More attributes!

在前一章中我们看到了如何填充VBO,配置顶点属性指针并将其存储在VAO中。这一次,我们还要向顶点数据添加颜色数据。我们将添加颜色数据作为3个浮点数到顶点数组。我们分别为三角形的每个角分配红、绿、蓝三种颜色:


float 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 
};    

因为我们现在有更多的数据要发送到顶点着色器,所以有必要调整顶点着色器来接收作为顶点属性输入的颜色值。注意,我们使用布局说明符将aColor属性的位置设置为1:


#version 330 core
layout (location = 0) in vec3 aPos;   // the position variable has attribute position 0
layout (location = 1) in vec3 aColor; // the color variable has attribute position 1
  
out vec3 ourColor; // output a color to the fragment shader

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

Since we no longer use a uniform for the fragment's color, but now use the ourColor output variable we'll have to change the fragment shader as well:

因为我们不再为片段的颜色使用一个uniform,但现在使用我们的颜色输出变量,我们同样将不得不改变片段着色器:


#version 330 core
out vec4 FragColor;  
in vec3 ourColor;
  
void main()
{
    FragColor = vec4(ourColor, 1.0);
}

因为我们添加了另一个顶点属性并更新了VBO的内存,我们必须重新配置顶点属性指针。更新后的数据在VBO的内存现在看起来有点像这样:

知道当前的布局,我们可以更新顶点格式通过glVertexAttribPointer:


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

glVertexAttribPointer的前几个参数相对简单。这次我们在属性位置1上配置顶点属性。颜色值的大小为3个浮点数,我们没有对这些值进行规范化。

因为我们现在有两个顶点属性,我们必须重新计算步幅值。为了得到数据数组中的下一个属性值(例如位置向量的下一个x分量),我们必须向右移动6个浮点数,3个位置值,3个颜色值。这为我们提供了以字节为单位的6倍浮点数大小的跨步值(= 24字节)。

另外,这次我们必须指定一个偏移量。对于每个顶点,位置顶点属性是第一个,因此我们声明一个偏移量为0。color属性在位置数据之后开始,因此偏移量为3 * sizeof(浮动)字节(= 12字节)。

运行该应用程序应该会出现以下图像:

如果您卡住了,请在这里here查看源代码。

图像可能不是你所期望的那样,因为我们只提供了3种颜色,而不是我们现在看到的巨大的调色板。这都是片段着色器中所谓的片段插值的结果。当渲染一个三角形时,光栅化阶段通常会导致比最初指定的顶点更多的片段。然后,光栅化程序根据片段在三角形上的位置确定每个片段的位置。

基于这些位置,它插值所有片段着色器的输入变量。例如,我们有一条线,上面的点是绿色的,下面的点是蓝色的。如果片段着色器运行在一个片段上,驻留在70%的线的位置,它的结果颜色输入属性将是绿色和蓝色的线性组合;更准确地说,是30%的蓝色和70%的绿色。

这就是在三角形中发生的事情。我们有3个顶点,因此3种颜色,从三角形的像素判断,它可能包含大约50000个片段,片段着色器在这些像素中插值颜色。如果你仔细看看这些颜色,你会发现这一切都是有道理的:红色到蓝色,首先是紫色,然后是蓝色。片段插值应用于所有片段着色器的输入属性。

Our own shader class

编写、编译和管理着色器是相当麻烦的。作为最后的触摸着色主体,我们将使我们的生活更容易一点,通过建立一个着色类,读取着色器从磁盘,编译和链接,检查错误,很容易使用。这也让您对如何将我们目前所学的一些知识封装成有用的抽象对象有了一些概念。

我们将完全在头文件中创建着色器类,主要是为了学习目的和可移植性。让我们从添加必需的include和定义类结构开始:


#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h> // include glad to get all the required OpenGL headers
  
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
  

class Shader
{
public:
    // the program ID
    unsigned int ID;
  
    // constructor reads and builds the shader
    Shader(const char* vertexPath, const char* fragmentPath);
    // use/activate the shader
    void use();
    // utility uniform functions
    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

我们在头文件的顶部使用了几个预处理器指令。使用这些代码行通知编译器只包括和编译这个头文件,如果它还没有被包括,即使多个文件包括着色器头。这防止了链接冲突。

着色器类持有着色器程序的ID。它的构造函数需要顶点着色器和片段着色器的源代码的路径,我们可以将它们作为简单的文本文件存储在磁盘上。为了增加一点额外的,我们也增加了一些实用功能,以减轻我们的生活一点:使用激活着色程序,和所有设置…函数查询统一位置并设置其值。

Reading from file

我们使用c++ filestreams将文件中的内容读入几个字符串对象中:


Shader(const char* vertexPath, const char* fragmentPath)
{
    // 1. retrieve the vertex/fragment source code from filePath
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // ensure ifstream objects can throw exceptions:
    vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
    fShaderFile.exceptions (std::ifstream::failbit | 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 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. compile shaders
unsigned int vertex, fragment;
int success;
char 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
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// print linking errors if any
glGetProgramiv(ID, GL_LINK_STATUS, &success);
if(!success)
{
    glGetProgramInfoLog(ID, 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 necessary
glDeleteShader(vertex);
glDeleteShader(fragment);

使用功能简单明了:


void use() 
{ 
    glUseProgram(ID);
}  

类似地,对于任何统一的setter函数:


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 class.。使用shader类是相当容易的;我们创建了一个着色对象,从那一点上简单地开始使用:


Shader ourShader("path/to/shaders/shader.vs", "path/to/shaders/shader.fs");
[...]
while(...)
{
    ourShader.use();
    ourShader.setFloat("someUniform", 1.0f);
    DrawStuff();
}

这里我们存储了顶点和片段着色器的源代码在两个文件称为shader.vs和shader.fs。你可以任意命名你的着色器文件;我个人觉得扩展.vs和.fs相当直观。

你可以在这里 here使用我们新创建的着色器类shader class.找到源代码。注意,你可以点击着色器文件路径来找到着色器的源代码。

Exercises

  1. Adjust the vertex shader so that the triangle is upside down: solution.
  2. Specify a horizontal offset via a uniform and move the triangle to the right side of the screen in the vertex shader using this offset value: solution.
  3. Output the vertex position to the fragment shader using the out keyword and set the fragment's color equal to this vertex position (see how even the vertex position values are interpolated across the triangle). Once you managed to do this; try to answer the following question: why is the bottom-left side of our triangle black?: solution

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值