LearnOpenGL note - Getting started:Shader

OpenGL入门系列2

Posted by Tao on Thursday, February 17, 2022

0 前言

本节笔记对应的内容 着色器
在这一节,我们会开始讲讲着色器(Shader)的内容,以及 OpenGL 着色器语言(GLSL)。

1 GLSL

着色器(Shader)是运行在GPU上的小程序,是使用一种叫GLSL的类C语言写成的。一个典型的着色器有下面的结构:

#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;
}
  • 第一行是声明所用的OpenGL版本
  • 接着是输入和输出这个shader的变量
  • 接下来是uniform
    shader
    什么是uniform呢?,根据上面的这幅图我们可以来理解。
    上图中的Vertex Shader的in和out已经接好了,从VAO中输入,输出到Fragment Shader。
    但是除此之外,如果还需要从CPU中拿什么参数(比如时间什么的),那么就要从额外的管道(uniform)输入进来。
  • 底下具体的shader代码也是包含在main函数当中,然后我们做一些操作之后,输出变量。
    输出的变量就会自动地往图形渲染管线的下一部分输送过去(在我们的例子中就是从Vertex Shader到了Fragment Shader),所以输出这端的名字也要和输入那端的变量名一样。
  • Vertex Shader一定会输出gl_Position变量给后面的流水线去处理。
  • 我们能声明的顶点属性是有上限的,它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用。

2 GLSL的数据类型

和其他编程语言一样,GLSL也有数据类型可以来指定变量的种类。
GLSL中包含C等其它语言大部分的默认基础数据类型:int、float、double、uint和bool。
GLSL也有两种容器类型,分别是向量(Vector)和矩阵(Matrix),其中矩阵会在之后的学习里再讨论。

GLSL中的向量是一个可以包含1到4个分量的容器,分量的类型可以是前面默认基础类型的任意一个。它们可以是下面的形式(n代表分量的数量):

  • vecn: the default vector of n floats.
  • bvecn: a vector of n booleans.
  • ivecn: a vector of n integers.
  • uvecn: a vector of n unsigned integers.
  • dvecn: a vector of n double components.

一个向量的分量可以通过vec.x这种方式获取,这里x是指这个向量的第一个分量。我们可以分别使用.x、.y、.z和.w来获取它们的第1、2、3、4个量。
GLSL允许我们对颜色使用rgba,或是对纹理坐标使用stpq来访问相同的位置。

向量这一数据类型也允许一些灵活的分量选择方式,叫做重组(Swizzling)

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

我们可以使用上面4个字母任意组合来创建一个和原来向量一样长的(同类型)新向量,只要原来向量有那些分量即可。
我们也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:

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关键字专门来实现输入输出。
每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。
vertex shader一定要输出vec4的gl_Position;fragment shader一定要输出vec4,因为片段着色器的功能是生成一个最终输出的颜色。
如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。
当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了。

4 给图形上色

我们在顶点着色器强制写入vertexColor变量: vertexColor

const char *vertexShaderSource = "#version 330 core\n"
                                 "layout (location = 0) in vec3 aPos;\n"
                                 "out vec4 vertexColor;\n"
                                 "void main()\n"
                                 "{\n"
                                 "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
                                 "  vertexColor=vec4(1.0, 0, 0, 1.0);\n"
                                 "}\0";

可以看到我们增加了一个输出变量vertexColor,指定为暗红色。
与之对应的我们改一下片段着色器的代码

const char *fragmentShaderSource = "#version 330 core\n"
                                   "in vec4 vertexColor;\n"
                                   "out vec4 FragColor;\n"
                                   "void main()\n"
                                   "{\n"
                                   "   FragColor = vertexColor;\n"
                                   "}\n\0";

这样,我们增加了一个输入变量,来接收顶点着色器传输出来的数据,并把这个数据赋值给FragColor,这样我们实现了简单的传递。

5 uniform

之前我们是从顶点着色器丢颜色到片段着色器,现在我们使用uniform直接丢给片段着色器。

const char *fragmentShaderSource = "#version 330 core\n"
                                   "in vec4 vertexColor;\n"
                                   "uniform vec4 ourColor;\n"
                                   "out vec4 FragColor;\n"
                                   "void main()\n"
                                   "{\n"
                                   "   FragColor = ourColor;\n"
                                   "}\n\0";

现在这个uniform还是空的,下面我们给它添加数据。
我们首先需要找到着色器中uniform属性的索引/位置值。
当我们得到uniform的索引/位置值后,我们就可以更新它的值了。
这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色,在渲染循环中加入以下代码(可以再上一节完整代码的glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);之后加上):

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);
  • 首先我们通过glfwGetTime()获取运行的秒数。然后我们使用sin函数让颜色在0.0到1.0之间改变,最后将结果储存到greenValue里。
  • 接着,我们用glGetUniformLocation查询uniform ourColor的位置值。
    我们为查询函数提供着色器程序和uniform的名字。
    如果glGetUniformLocation返回-1就代表没有找到这个位置值。
  • 最后,我们可以通过glUniform4f函数设置uniform值。
  • 注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先使用程序(调用glUseProgram),因为它是在当前激活的着色器程序中设置uniform的。

6 More Attribute

为三角形三个顶点增加颜色值。

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

更新后的VBO内存中的数据看起来像这样:
vertex_attribute_pointer_interleaved
现在知道了VBO的排列布局,现在更新对应的VAO,利用glVertexAttribPointer

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

color的起始偏移量为3,类型为void*,所以参数写为(void *)(3 * sizeof(float))
而步长值则全部更新为6

运行程序你应该会看到如下结果:
fragment
这个图片可能不是你所期望的那种,因为我们只提供了3个颜色,而不是我们现在看到的大调色板。这是在片段着色器中进行的所谓**片段插值(Fragment Interpolation)的结果。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。
基于这些位置,它会
插值(Interpolate)**所有片段着色器的输入变量。比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝 + 70%绿。

7 抽象着色器类

在代码目录下新建Shader.h和Shader.cpp两个文件并配置相关CMakeLists文件
cmakelist

7.1 从文件读取

我们使用C++文件流读取着色器内容,储存到几个字符串对象里。
如果不太了解文件流,可以这样理解:
filesystem.png

  • 左边是CPU,旁边是内存,最右边是硬盘,如果硬盘上有个文件是f,我们要怎么读取呢
  • 我们要在内存中开辟一块FileBuffer,先把文件从硬盘放到这里面。
  • 硬盘里有很多文件,要怎么操作我们是不知道的,所以要用StringBuffer把FileBuffer做个诠释。
  • OpenGL不操作String,操作的是char数组,所以我们要一个String的缓存把StringBuffer里的东西变成char数组。

所以cpp文件中的shader建构函数会是这样:

  • h文件
#pragma once
#include <string>
class Shader
{
public:
    Shader(const char* vertexPath, const char* fragmentPath);
    std::string vertexString;
    std::string fragmentString;
    const char* vertexSource;
    const char* fragmentSource;
};
  • cpp文件
#include "Shader.h"
#include <iostream>
#include <fstream>
#include <SStream>

using namespace std;

Shader::Shader(const char* vertexPath, const char* fragmentPath)
{
    //从文件路径中获取顶点/片段着色器
    ifstream vertexFile;
    ifstream fragmentFile;
    stringstream vertexSStream;
    stringstream fragmentSStream;
    //打开文件
    vertexFile.open(vertexPath);
    fragmentFile.open(fragmentPath);
    //保证ifstream对象可以抛出异常:
    vertexFile.exceptions(ifstream::failbit || ifstream::badbit);
    fragmentFile.exceptions(ifstream::failbit || ifstream::badbit);

    try
    {
        if (!vertexFile.is_open() || !fragmentFile.is_open()) 
        {
            throw exception("open file error");
        }

        //读取文件缓冲内容到数据流
        vertexSStream << vertexFile.rdbuf();
        fragmentSStream << fragmentFile.rdbuf();

        //转换数据流到string
        vertexString = vertexSStream.str();
        fragmentString = fragmentSStream.str();

        vertexSource = vertexString.c_str();
        fragmentSource = fragmentString.c_str();

    }
    catch (const std::exception& ex)
    {
        printf(ex.what());
    }
}

我们也在项目中建立vertexSource.txt和fragmentSource.txt这两个文件,把之前着色器的代码粘贴进去即可

7.2 编译和链接着色器

下一步,我们需要编译着色器。在fragmentSource = fragmentString.c_str();之后加上

// 编译着色器
unsigned int vertex, fragment;
// 顶点着色器
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, &vertexSource, NULL);
glCompileShader(vertex);
// 片段着色器
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, &fragmentSource, NULL);
glCompileShader(fragment);

而后我们要链接着色器,在h文件中加上

unsigned int ID;  //Shader Program ID

在cpp文件加上

// 着色器程序
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// 删除着色器,它们已经链接到我们的程序中了,已经不再需要了
glDeleteShader(vertex);
glDeleteShader(fragment);

使用函数use非常简单:.h文件追加void use();
cpp文件追加以下内容:

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

7.3 错误日志

我们现在的代码没有错误日志的提示,所以我们为了日后的方便实用,添加纠错的功能。
在h文件中加上纠错函数的声明

private:
    void checkCompileErrors(unsigned int ID, std::string type);

在cpp文件中定义该 函数

void Shader::checkCompileErrors(unsigned int ID, std::string type)
{
    int success;
    char infoLog[512];

    if (type != "PROGRAM")
    {
        glGetShaderiv(ID, GL_COMPILE_STATUS, &success);
        if (!success)
        {
            glGetShaderInfoLog(ID, 512, NULL, infoLog);
            std::cout << "shader compile error:" << infoLog << std::endl;
        }
    }
    else
    {
        glGetProgramiv(ID, GL_LINK_STATUS, &success);
        if (!success)
        {
            glGetProgramInfoLog(ID, 512, NULL, infoLog);
            std::cout << "program link error:" << infoLog << std::endl;
        }
    }
}

在建构函数的着色器编译语句,以及链接的语句后面使用纠错函数,比如checkCompileErrors(vertex, "VERTEX");
现在就可以进行纠错提示的功能了。

那么glGetShaderiv函数中的iv是什么意思呢?
glGetShaderiv它描述返回的参数,在这种情况下矢量的整数。例如,相同的术语用于glTexParameterivglTexParameterfv,其分别更新整数或浮点数的向量。

完整代码

「如果这篇文章对你有用,请随意打赏」

Heisenberg Blog

如果这篇文章对你有用,请随意打赏

使用微信扫描二维码完成支付


comments powered by Disqus