探索 OpenGL 音视频渲染技术(5):着色器

这个系列文章我们来介绍一位海外工程师如何探索 OpenGL 音视频渲染技术,对于想要开始学习音视频技术的朋友,这些文章是份不错的入门资料,这是第 5 篇:OpenGL 着色器。

—— 来自公众号“关键帧Keyframe”的分享

正如在《三角形入门》一章中提到的,着色器是位于 GPU 上的小程序。这些程序在图形管道的每个特定部分运行。从基本意义上讲,着色器不过是将输入转换为输出的程序。着色器也是非常独立的程序,它们之间不允许相互通信;它们唯一的通信方式是通过它们的输入和输出。

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

1、GLSL

着色器是用类似 C 的语言 GLSL 编写的。GLSL 专为图形使用而设计,包含专门针对向量和矩阵操作的有用功能。

着色器总是以版本声明开头,后面是输入和输出变量列表、uniform 和主函数。每个着色器的入口点是其主函数,在那里我们处理任何输入变量并将结果输出到其输出变量。如果你不知道 uniform 是什么,别担心,我们很快就会讲到。

着色器通常具有以下结构:

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

当我们专门讨论顶点着色器时,每个输入变量也被称为顶点属性。顶点属性的最大数量受到硬件的限制。OpenGL 保证至少有 16 个 4 分量顶点属性可用,但某些硬件可能允许更多,你可以通过查询 GL_MAX_VERTEX_ATTRIBS来获取:

int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "支持的最大顶点属性数: " << nrAttributes << std::endl;

这通常返回 16,这应该足以满足大多数目的。

2、类型

GLSL 像任何其他编程语言一样,有数据类型用于指定我们想要处理的变量类型。GLSL 包含我们从 C 等语言中知道的大多数默认基本类型:intfloatdoubleuint和 bool。GLSL 还具有两种容器类型,我们将大量使用,即 向量和 矩阵。我们将在后面的章节中讨论矩阵。

2.1、向量

GLSL 中的向量是一个包含 2、3 或 4 个分量的容器,用于存储上述任何基本类型。它们可以有以下形式(n表示分量的数量):

  • vecnn个浮点数的默认向量。
  • bvecnn个布尔值的向量。
  • ivecnn个整数的向量。
  • uvecnn个无符号整数的向量。
  • dvecnn个双精度分量的向量。

大多数情况下,我们将使用基本的 vecn,因为浮点数足以满足我们的大多数目的。

可以通过 vec.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);

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

3、输入和输出

着色器是独立的小程序,但它们是整体的一部分,因此我们希望在各个着色器上拥有输入和输出,以便我们可以传递数据。GLSL 特别定义了 in和 out关键字用于此目的。每个着色器可以使用这些关键字指定输入和输出,只要下一个着色器阶段的输出变量与输入变量匹配,它们就会被传递。顶点和片段着色器略有不同。

顶点着色器应该接收某种形式的输入,否则它将非常无效。顶点着色器的输入不同,因为它直接从顶点数据接收输入。为了定义顶点数据的组织方式,我们使用位置元数据指定输入变量,以便我们可以在 CPU 上配置顶点属性。我们在上一章中看到了这一点,即 layout (location = 0)。因此,顶点着色器需要为其输入额外的布局规范,以便我们将其与顶点数据链接。

也可以省略 layout (location = 0)限定符,并通过 glGetAttribLocation在 OpenGL 代码中查询属性位置,但我更倾向于在顶点着色器中设置它们。这样更容易理解,并且可以节省你(和 OpenGL)的一些工作。

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

因此,如果我们想从一个着色器发送数据到另一个着色器,我们需要在发送着色器中声明一个输出,在接收着色器中声明一个类似的输入。当两边的类型和名称相同时,OpenGL 将这些变量链接在一起,然后就可以在着色器之间发送数据(这在链接程序对象时完成)。为了说明这在实践中是如何工作的,我们将修改上一章的着色器,让顶点着色器决定片段着色器的颜色。

顶点着色器

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

你可以看到我们在顶点着色器中声明了一个 vec4输出变量 vertexColor,并在片段着色器中声明了一个类似的 vertexColor输入变量。由于它们的类型和名称相同,片段着色器中的 vertexColor与顶点着色器中的 vertexColor链接在一起。由于我们在顶点着色器中将颜色设置为深红色,结果片段也应为深红色。下图显示了输出:

探索 OpenGL 音视频渲染技术(5):着色器

我们刚刚成功地从顶点着色器发送了一个值到片段着色器。让我们稍微加点料,看看是否可以将颜色从我们的应用程序发送到片段着色器!

4、Uniforms(统一变量)

Uniforms 是另一种从 CPU 上的应用程序将数据传递到 GPU 上的着色器的方法。与顶点属性相比,uniforms 有几个不同之处。首先,uniforms 是全局的。全局意味着 uniform 变量在着色器程序对象中是唯一的,并且可以从着色器程序的任何阶段的着色器中访问。其次,无论你将 uniform 值设置为什么,uniforms 都会保持其值,直到它们被重置或更新。

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

#version 330 core
out vec4 FragColor;

uniform vec4 ourColor; // 我们在 OpenGL 代码中设置这个变量

void main()
{
    FragColor = ourColor;
}

我们在片段着色器中声明了一个 vec4uniform ourColor,并将片段的输出颜色设置为此 uniform 的内容。由于 uniforms 是全局变量,我们可以在任何着色器阶段定义它们,因此不需要再次通过顶点着色器将数据传递到片段着色器。我们不在顶点着色器中使用此 uniform,因此不需要在那里定义它。

如果你声明了一个在 GLSL 代码中未使用的 uniform,编译器会默默地将其从编译版本中移除,这会导致几个令人沮丧的错误;请记住这一点!

目前 uniform 是空的;我们还没有向 uniform 添加任何数据,所以让我们尝试这样做。我们首先需要找到 uniform 属性在着色器中的索引/位置。一旦我们有了 uniform 的索引/位置,我们就可以更新其值。这次,我们不是向片段着色器传递单一颜色,而是通过随着时间变化来改变颜色来加点料:

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()获取以秒为单位的运行时间。然后我们使用正弦函数在 0.0到 1.0的范围内变化颜色,并将结果存储在 greenValue中。

然后我们使用 glGetUniformLocation查询 ourColoruniform 的位置。我们向查询函数提供着色器程序和 uniform 的名称(我们想要获取其位置的 uniform)。如果 glGetUniformLocation返回 -1,则表示未能找到位置。最后,我们可以使用 glUniform4f函数设置 uniform 值。注意,查找 uniform 位置不需要先使用着色器程序,但更新 uniform 需要先使用程序(通过调用 glUseProgram),因为它设置的是当前活动着色器程序的 uniform。

由于 OpenGL 在其核心是一个 C 库,它没有函数重载的原生支持,因此每当一个函数可以用不同类型的参数调用时,OpenGL 会为每种所需类型定义新函数;glUniform是一个完美的例子。该函数需要一个特定的后缀,用于你想要设置的 uniform 类型。一些可能的后缀包括:

  • f:函数期望一个 float作为其值。
  • i:函数期望一个 int作为其值。
  • ui:函数期望一个 unsigned int作为其值。
  • 3f:函数期望 3 个 float作为其值。
  • fv:函数期望一个 float向量/数组作为其值。

每当你想要配置 OpenGL 的选项时,只需选择与你的类型对应的重载函数即可。在我们的情况下,我们想要分别设置 uniform 的 4 个 float值,因此我们通过 glUniform4f传递我们的数据(注意我们也可以使用 fv版本)。

现在我们知道了如何设置 uniform 变量的值,我们可以使用它们进行渲染。如果我们希望颜色逐渐变化,我们希望每帧更新此 uniform,否则如果只设置一次,三角形将保持单一纯色。因此,我们计算 greenValue并在每次渲染迭代中更新 uniform:

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

    // 渲染
    // 清除颜色缓冲区
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // 确保激活着色器
    glUseProgram(shaderProgram);

    // 更新 uniform 颜色
    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);
    glDrawArrays(GL_TRIANGLES, 0, 3);

    // 交换缓冲区并轮询 IO 事件
    glfwSwapBuffers(window);
    glfwPollEvents();
}

这段代码是对之前代码的相对直接的改编。这次,我们在绘制三角形之前每帧更新一个 uniform 值。如果你正确更新了 uniform,你应该会看到三角形的颜色从绿色逐渐变为黑色,然后再变回绿色。

探索 OpenGL 音视频渲染技术(5):着色器

如果你遇到问题,可以查看这里的源代码。

正如你所见,uniforms 是设置每帧可能变化的属性或在应用程序和着色器之间交换数据的有用工具,但如果我们想为每个顶点设置一个颜色呢?在这种情况下,我们不得不为每个顶点声明许多 uniforms。一个更好的解决方案是包含更多的顶点属性数据,这就是我们现在要做的。

5、更多属性!

在上一章中,我们看到了如何填充 VBO、配置顶点属性指针并将所有内容存储在 VAO 中。这次,我们还希望将颜色数据添加到顶点数据中。我们将在顶点数组中将颜色数据作为 3 个 float添加。我们分别为三角形的每个角分配红色、绿色和蓝色:

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    // 顶部
};

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

#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.0);
    ourColor = aColor; // 将 ourColor 设置为我们从顶点数据中获得的输入颜色
}

由于我们不再使用片段颜色的 uniform,而是现在使用 ourColor输出变量,我们也需要更改片段着色器:

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

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

由于我们添加了另一个顶点属性并更新了 VBO 的内存,我们需要重新配置顶点属性指针。VBO 内存中的更新数据现在看起来像这样:

探索 OpenGL 音视频渲染技术(5):着色器

了解当前布局后,我们可以使用 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的前几个参数相对简单。这次我们正在配置属性位置 1上的顶点属性。颜色值有 3个 float分量,我们不归一化这些值。

由于我们现在有两个顶点属性,我们需要重新计算 步长值。为了获取数据数组中的下一个属性值(例如位置向量的下一个 x分量),我们需要向右移动 6个 float,三个用于位置值,三个用于颜色值。这给出了一个步长值,即 6 乘以 float的字节大小(= 24字节)。

此外,这次我们必须指定一个偏移量。对于每个顶点,位置顶点属性在先,所以我们声明偏移量为 0。颜色属性在位置数据之后开始,因此偏移量为 3 * sizeof(float)字节(= 12字节)。

运行应用程序应该得到以下图像:

探索 OpenGL 音视频渲染技术(5):着色器

图像可能并不完全是你所期望的,因为我们只提供了 3 种颜色,而不是我们现在看到的巨大调色板。这全部是片段着色器中所谓的片段插值的结果。当渲染三角形时,光栅化阶段通常会生成比最初指定的顶点更多的片段。光栅化器然后根据这些片段在三角形形状上的位置来确定它们的位置。

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

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

6、我们的着色器类

编写、编译和管理着色器可能相当繁琐。作为着色器主题的最后润色,我们将通过构建一个着色器类来让生活更轻松,该类从磁盘读取着色器,编译和链接它们,检查错误,并且易于使用。这也让你有点了解我们如何将我们学到的一些知识封装到有用的抽象对象中。

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

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h> // 包含 glad 以获取所有所需的 OpenGL 头文件

#include <string>
#include <fstream>
#include <sstream>
#include <iostream>

class Shader
{
public:
    // 程序 ID
    unsignedint ID;

    // 构造函数读取并构建着色器
    Shader(constchar* vertexPath, constchar* fragmentPath);
    // 使用/激活着色器
    void use();
    // utility 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

我们在头文件的顶部使用了几个预处理器指令。使用这些代码行通知编译器,如果尚未包含此头文件,则只包含和编译此头文件,即使多个文件包含着色器头文件。这可以防止链接冲突。

着色器类保存着色器程序的 ID。其构造函数需要顶点和片段着色器源代码的文件路径,我们可以将它们作为简单的文本文件存储在磁盘上。为了增加一些额外的功能,我们还添加了几个实用函数,让生活更轻松:use激活着色器程序,所有 set...函数查询 uniform 位置并设置其值。

7、从文件读取

我们使用 C++ 文件流将内容从文件读取到几个 string对象中:

Shader(const char* vertexPath, constchar* fragmentPath)
{
    // 1. 从 filePath 检索顶点/片段源代码
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;
    std::ifstream fShaderFile;
    // 确保 ifstream 对象可以抛出异常:
    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_SUCCESFULLY_READ" << std::endl;
    }
    constchar* vShaderCode = vertexCode.c_str();
    constchar* fShaderCode = fragmentCode.c_str();
    [...]

接下来,我们需要编译和链接着色器。请注意,我们还会检查编译/链接是否失败,如果是这样,则打印编译时错误。这在调试时非常有用(你最终会需要这些错误日志):

// 2. 编译着色器
unsignedint vertex, fragment;
int success;
char infoLog[512];

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

// 片段着色器类似
[...]

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

// 删除着色器,因为它们现在已经被链接到我们的程序中,不再需要
glDeleteShader(vertex);
glDeleteShader(fragment);

use函数很简单:

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("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非常直观。

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

8、练习

  1. 调整顶点着色器,使三角形倒置:解决方案。
  2. 通过 uniform 指定水平偏移量,并在顶点着色器中使用此偏移量将三角形移动到屏幕的右侧:解决方案。
  3. 使用 out关键字将顶点位置输出到片段着色器,并将片段的颜色设置为等于此顶点位置(看看三角形的顶点位置值是如何在三角形上插值的)。一旦你成功做到这一点,试着回答以下问题:为什么我们三角形的左下部分是黑色的?:解决方案。

音视频方向学习、求职,欢迎加入我们的星球

丰富的音视频知识、面试题、技术方案干货分享,还可以进行面试辅导

探索 OpenGL 音视频渲染技术(5):着色器

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。

(0)

相关推荐

发表回复

登录后才能评论