[!TIP]
前面我们已经介绍了如何使用OpenGL创建一个窗口,绘制简单三角形,多边形(四边形),希望复习的同学可以参看我之前的博客。
从零开始写游戏引擎(开发环境VS2022+OpenGL)之三 HelloWorld窗口的实现,代码与解释,步步教会系列-优快云博客
从零开始写游戏引擎(开发环境VS2022+OpenGL)之四 GPU简单作图的实现,代码与解释,保姆包教会系列-优快云博客
如果是对如何设置VS2022+OpenGL的开发环境有问题,可以参看下面一篇博文。
好了,下面开始正题,先介绍一下今天的学习目标。编写一个可以随着时间变化的三角形。
编写一个多彩的三角形
好了下面进入正题!!!
着色器
正如在上一章节中提到的,着色器是基于GPU的小程序。这些程序针对图形管道的每个特定部分运行。从基本意义上讲,着色器只不过是将输入(input)转换为输出(output)的程序。着色器也是非常孤立的程序,因为它们不允许彼此通信;他们唯一的交流是通过他们的输入和输出。
在前一章中,我们简要地介绍了着色器的表面以及如何正确使用它们。现在我们将以更一般的方式解释着色器,特别是OpenGL着色语言(GLSL)。
OpenGL着色语言(GLSL)
函数是用类c语言GLSL编写的。GLSL是专门为图形使用而定制的,它包含了专门针对矢量和矩阵操作的有用功能。
着色器总是以版本声明开始,然后是输入和输出变量,制服及其主要功能的列表。每个着色器的入口点都在其主要功能中,我们处理任何输入变量并在其输出变量中输出结果。如果你不知道制服是什么也不用担心,我们很快就会讲到。
着色器通常有以下结构:
#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-component顶点属性可用,但一些硬件可能允许更多,你可以通过查询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、int和bool。GLSL还提供了我们将经常使用的两种容器类型,即向量和矩阵。我们将在后面的章节中讨论矩阵。
向量
在GLSL中,向量是针对刚才提到的任何基本类型的2、3或4个组件的容器。它们可以采用以下形式(n表示组件的数量):
[!NOTE]
vecn: n个浮点数的默认向量。
bvecn:一个有n个布尔值的向量。
ivecn:一个n个整数的向量。
uvecn: n个无符号整数的向量。
dvecn: n个双分量的向量。
大多数情况下,我们将使用基本的vecn,因为浮点数对于我们的大多数目的来说已经足够了。
向量的分量可以通过vec.x来访问。其中x是向量的第一个分量。您可以使用vec.x,vec.y,vec.z和vec.w分别访问它们的第一、第二、第三和第四个组件。GLSL还允许您使用rgba作为颜色或stpq作为纹理坐标,访问相同的组件。
矢量数据类型允许一些有趣而灵活的组件选择,称为swizzling。Swizzling允许我们使用这样的语法:
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;
[!TIP]
在这个方面,是不是觉得blender的变量使用是不是有这个味了,所以,我们的博客系列可以改名叫干爆blender系列,自己会做3D引擎了,还留着Blender干嘛?
您可以使用最多4个字母的任何组合来创建一个新的矢量(相同类型),只要原始矢量具有这些组件;例如,不允许访问vec2的.z组件。还可以将vector作为实参传递给不同的vector构造函数调用,从而减少所需的实参数量:
vec2 vect = vec2(0.5, 0.7);
vec4 result = vec4(vect, 0.0, 0.0);
vec4 otherResult = vec4(result.xyz, 1.0);
因此,向量是一种灵活的数据类型,可以用于所有类型的输入和输出。在整个博客系列文章中,您将看到我们如何创造性地管理向量的大量示例。
in和out关键字
着色器本身是很好的小程序,但它们是整体的一部分,因此我们希望在单个着色器上有输入和输出,以便我们可以移动内容。GLSL为此专门定义了in和out关键字。每个着色器都可以使用这些关键字指定输入和输出,只要输出变量与下一个着色器阶段的输入变量匹配,它们就会被传递下去。顶点和片段着色器略有不同。
顶点着色器应该接收某种形式的输入,否则它将是相当无效的。顶点着色器的输入不同,因为它直接从顶点数据接收输入。为了定义如何组织顶点数据,我们使用位置元数据指定输入变量,这样我们就可以在CPU上配置顶点属性。我们在上一章看到过布局(location = 0),因此顶点着色器需要一个额外的布局规范,以便我们可以将它与顶点数据链接起来。
[!TIP]
也可以省略“布局(位置= 0)”说明符,并通过glGetAttribLocation查询OpenGL代码中的属性位置,但我更喜欢在顶点着色器中设置它们。它更容易理解,并为您(和OpenGL)节省了一些工作。
另一个例外是片段着色器需要vec4颜色输出变量,因为片段着色器需要生成最终输出颜色。如果你没有在你的片段着色器中指定输出颜色,那么这些片段的颜色缓冲输出将是未定义的(这通常意味着OpenGL将渲染它们黑色或白色)。
所以如果我们想要将数据从一个着色器发送到另一个着色器,我们必须在发送着色器中声明一个输出,在接收着色器中声明一个类似的输入。当两边的类型和名称相等时,OpenGL将把这些变量链接在一起,然后可以在着色器之间发送数据(这是在链接程序对象时完成的)。为了向你展示这在实践中是如何工作的,我们将改变上一章的着色器,让顶点着色器决定片段着色器的颜色。
顶点着色器:
#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
}
片段着色器:
#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。因为我们在顶点着色器中设置了暗红色,所以生成的片段也应该是暗红色。
源代码可以参见博主上传的资源文件:VS2022+OpenGL开发环境下的暗红色三角形图形绘制源代码资源-优快云文库
输出如下图:
好了!我们只是设法从顶点着色器发送一个值到片段着色器。下面让我们再来加点猛料,看看我们是否可以从我们的应用程序,发送一个颜色到片段着色器!换句话说,我们现在就要实现,在3D游戏引擎主程序里面,改变片段着色器的渲染颜色。
[!TIP]
这里实现绘制功能的源码可以下面的链接找到:
VS2022+OpenGL开发环境下的暗红色三角形图形绘制源代码资源-优快云文库
对于源码的解释,除了上述的着色器部分的解释,其余大部分内容都可以在上一篇博客文章中找到。请参考下面一篇博文。
从零开始写游戏引擎(开发环境VS2022+OpenGL)之四 GPU简单作图的实现,代码与解释,保姆包教会系列-优快云博客
Uniforms关键字类型
Uniforms类型是将数据从CPU上的应用程序传递到GPU上的着色器的另一种方式。然而,与顶点属性相比,Uniforms类型略有不同。首先,Uniforms类型是全局的,意味着一个统一的变量在每个着色器程序对象中是唯一的,并且可以在着色器程序的任何阶段,从任何着色器访问。其次,无论你将Uniforms类型的值设置为什么,Uniforms类型变量将保持它们的值,直到它们被重置或更新。
要在GLSL中声明Uniforms类型变量,我们只需将Uniforms类型添加到带有类型和名称的着色器中。从那时起,我们可以在着色器中使用新声明的Uniforms类型。让我们看看这次我们是否可以通过Uniforms类型的方式来设置三角形的颜色:
#version 330 core
out vec4 FragColor;
uniform vec4 ourColor; // we set this variable in the OpenGL code.
void main()
{
FragColor = ourColor;
}
我们在片段着色器中声明了一个Uniforms类型的vec4 ourColor,并将片段的输出颜色设置为这个Uniforms类型值的内容。由于Uniforms类型是全局变量,我们可以在任何着色阶段定义它们,所以不需要再次通过顶点着色器来获得片段着色器。我们没有在顶点着色器中使用这个Uniforms类型变量,所以没有必要在那里定义它。
[!WARNING]
如果你声明了一个在你的GLSL代码中没有使用的Uniforms类型变量,编译器将从编译的版本中默默地删除这个变量,这是导致几个令人沮丧的错误的原因;记住这一点!
Uniforms类型变量目前是空的;我们还没有给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,这个Uniforms类型变量的位置。我们向查询函数提供着色程序和Uniforms类型变量的名称(我们想从中检索位置)。如果glGetUniformLocation返回-1,则无法找到该位置。最后,我们可以使用glUniform4f函数设置Uniforms类型变量值。注意,找到Uniforms类型变量的位置并不需要你首先使用着色器程序,但是更新Uniforms类型变量需要你首先使用程序(通过调用glUseProgram),因为它在当前活动的着色器程序上设置Uniforms类型变量数值。
[!IMPORTANT]
因为OpenGL的核心是一个C库,所以它没有对函数重载的原生支持,所以无论一个函数可以用不同的类型调用,OpenGL都会为所需的每种类型定义新的函数;glUniform就是一个很好的例子。该函数需要为要设置的Uniforms类型指定一个后缀。一些可能的后缀有:
f:函数期望一个浮点数作为它的值。
i:函数期望int作为它的值。
ui:函数期望一个unsigned int作为它的值。
3f:该函数期望3个浮点数作为其值。
fv:该函数期望float向量/数组作为其值。
当你想配置OpenGL的一个选项时,只需选择与你的类型相对应的重载函数。在我们的例子中,我们想要分别设置4个统一的浮点数,所以我们通过glUniform4f传递数据(注意,我们也可以使用fv版本)。
现在我们知道了如何设置Uniforms变量的值,我们可以使用它们进行呈现。如果我们希望颜色逐渐变化,我们希望每帧更新这个Uniforms变量,否则如果我们只设置一次,三角形将保持单一的纯色。因此,我们计算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();
}
该代码是对前面代码的相对直接的改编。这一次,我们在绘制三角形之前每帧更新一个统一的值。如果你正确地更新了制服,你应该会看到三角形的颜色逐渐从绿色变为黑色,然后又变回绿色。
源代码可以在这里找到:VS2022+OpenGL开发环境,从零开始编写3D游戏引擎,使用着色器编写一个能够改变颜色的三角形资源-优快云文库
效果图如下:
更近一步
正如你所看到的,Uniforms是一个很有用的工具,可以用来设置每一帧可能改变的属性,或者在应用程序和着色器之间交换数据,但是如果我们想为每个顶点设置颜色呢?在这种情况下,我们必须声明和我们的顶点一样多的制服。一个更好的解决方案是在顶点属性中包含更多的数据。这就是我们现在要做的。
更加多的属性
在前一章中,我们看到了如何填充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
}
因为我们不再使用一个Uniforms类型来表示片段的颜色,但现在使用ourColor输出变量,我们将不得不改变片段着色器:
#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个浮点数,三个用于位置值,三个用于颜色值。这给了我们一个6倍于浮点数大小的步长值(= 24字节)。
同样,这次我们必须指定一个偏移量。对于每个顶点,位置顶点属性是第一个,所以我们声明偏移量为0。颜色属性在位置数据之后开始,所以偏移量是3 * sizeof(float),单位是字节(= 12字节)。
运行应用程序应该会产生以下图像:
源代码可以参看本人上传的资源VS2022+OpenGL开发环境,从零开始编写3D游戏引擎,使用着色器编写一个多彩色的三角形资源-优快云文库
图像可能不完全是你所期望的,因为我们只提供3种颜色,而不是我们现在看到的巨大的调色板。这都是片段着色器中片段插值的结果。当渲染一个三角形时,光栅化阶段通常会产生比最初指定的顶点更多的碎片。光栅化器然后根据每个碎片在三角形上的位置确定它们的位置。
基于这些位置,它插入所有片段着色器的输入变量。比如说,我们有一条直线,上面的点是绿色的,下面的点是蓝色的。如果片段着色器运行在位于线的70%位置的片段上,则其产生的颜色输入属性将是绿色和蓝色的线性组合;更准确地说:30%的蓝色和70%的绿色。
这就是在三角区发生的事。我们有3个顶点,因此有3种颜色,从三角形的像素判断,它可能包含大约50000个碎片,其中碎片着色器在这些像素之间插值颜色。如果你仔细观察颜色,你会发现这一切都是有道理的:从红色到蓝色,先到紫色,然后到蓝色。片段插值应用于所有片段着色器的输入属性。
如何写一个自己的着色器类?
编写、编译和管理着色器可能相当麻烦。作为着色器主题的最后一笔,我们将通过构建一个着色器类来简化我们的生活,该类可以从磁盘读取着色器,编译并链接它们,检查错误并且易于使用。这也给了你一点想法,我们如何将我们学到的一些知识封装成有用的抽象对象。
我们将完全在头文件中创建着色器类,主要是为了学习目的和可移植性。让我们从添加必需的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
[!CAUTION]
我们在头文件的顶部使用了几个预处理器指令。使用这些小行代码通知你的编译器只包含和编译这个头文件,如果它还没有被包含,即使多个文件包含着色头文件。这可以防止链接冲突。
shader类保存着shader程序的ID。它的构造函数需要顶点和片段着色器的源代码的文件路径,我们可以将其作为简单的文本文件存储在磁盘上。为了添加一点额外的东西,我们还添加了一些实用功能来缓解我们的生活:使用use函数激活着色器程序,所有set…函数查询uniforms变量位置并设置其值。
读取文件
我们使用c++文件流从文件中读取内容到几个字符串对象中:
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);
use 函数也是比较直白的:
void use()
{
glUseProgram(ID);
}
类似的,还有:
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类。使用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非常直观。
所有的源代码包括vs以及fs文件都可以在这里找到:VS2022+OpenGL开发环境,从零开始编写3D游戏引擎,如何写自己的着色器类从而编写一个多彩色的三角形资源-优快云文库
很好,今天的内容到这里就结束了。
希望大家更多点赞关注以及收藏,您的支持就是我最大的动力,谢谢大家!