chelsea 发表于 2024-5-9 14:02:59

学习笔记3——OpenGL着色器(shader)

本帖最后由 chelsea 于 2024-5-11 21:54 编辑

> 本帖最后由 chelsea 于 2024-5-11 15:27 编辑

> 本帖最后由 chelsea 于 2024-5-9 15:11 编辑

> 本帖最后由 chelsea 于 2024-5-9 14:09 编辑

本文是LearnOpenGL文档[第五节](https://learnopengl-cn.readthedocs.io/zh/latest/01%20Getting%20started/05%20Shaders/)《Shaders》的学习笔记。(感慨一下,如果以前一点都没接触过的话,最好还是从第一节慢慢学,不然很容易学不明白看不懂,甚至有很多误解!)

# OpenGL着色器语言——GLSL

着色器(Shader)是运行在GPU上的小程序,把输入转化为输出。

着色器的开头需要声明版本,接着是输入和输出变量、uniform和main函数。每个着色器的入口都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量。一个典型的着色器有下面的结构:

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

当我们特别谈论到顶点着色器的时候,每个输入变量也叫顶点属性(Vertex Attribute)。

# 数据类型

GLSL中包含C等其它语言大部分的默认基础数据类型:int、float、double、uint和bool,还有两种容器类型:向量(Vector)和矩阵(Matrix)。

## 向量

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

!(data/attachment/forum/202405/11/153519uraerexzjeln446l.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

一个向量的分量可以通过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;
```

也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:

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

# 输入与输出

每个着色器使用in和out两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。

## 特殊之处——顶点着色器的输入

顶点着色器的输入是特殊的,它从顶点数据中直接接收输入。为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。顶点着色器需要为它的输入提供一个额外的layout标识,这样我们才能把它链接到顶点数据。【这一段完全没看懂】

*也可以忽略layout (location = 0)标识符,通过使用glGetAttribLocation查询属性位置值(Location),但是在着色器中设置它们,会更容易理解而且节省你(和OpenGL)的工作量。*

## 特殊之处——片段着色器的输出

片段着色器需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。

如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了。举个例子,让顶点着色器为片段着色器决定颜色:

顶点着色器

```
#version 330 core
layout (location = 0) in vec3 position; // position变量的属性位置值为0

out vec4 vertexColor; // 为片段着色器指定一个颜色输出

void main()
{
    gl_Position = vec4(position, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
    vertexColor = vec4(0.5f, 0.0f, 0.0f, 1.0f); // 把输出变量设置为暗红色
}
```

片段着色器

```
#version 330 core
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)

out vec4 color; // 片段着色器输出的变量名可以任意命名,类型必须是vec4

void main()
{
    color = vertexColor;
}
```

# Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式。它是全局的(Global),因此它在每个着色器程序对象中都必须是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。

接下来,我们尝试通过uniform设置三角形的颜色:

```
#version 330 core
out vec4 color;

uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量

void main()
{
    color = ourColor;
}
```

这是一个片段着色器,因为uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介(因为按理说如果要从外部获取数据必须通过顶点着色器!)。顶点着色器中不需要这个uniform,所以我们不用在那里定义它。

*注意:如果你声明了一个uniform却在GLSL代码中没用过,编译器会默默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致几个非常麻烦的错误,记住这点!*

这个uniform现在还是空的。为了给它赋值,首先需要找到着色器中uniform属性的索引/位置值,然后就可以更新它的值了。这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:

```
GLfloat timeValue = glfwGetTime();
GLfloat greenValue = (sin(timeValue) / 2) + 0.5;
GLint vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");//返回-1代表没有找到
glUseProgram(shaderProgram);
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);
```

注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个unform之前你必须先使用程序(调用glUseProgram),因为它是在当前激活的着色器程序中设置unform的。

OpenGL不支持函数重载,在函数参数不同的时候就要定义新的函数。glUniform是一个典型例子。这个函数有一个特定的后缀,标识设定的uniform的类型。可能的后缀有:

!(data/attachment/forum/202405/11/163223eg497b5bfibijtgj.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

设置好uniform的值后,就可以使用它来渲染了。如果我们打算让颜色慢慢变化,就要在game loop的每次迭代中,绘制三角形前先更新uniform值:

```
while(!glfwWindowShouldClose(window))
{
    // 检测并调用事件
    glfwPollEvents();

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

    // 记得激活着色器
    glUseProgram(shaderProgram);

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

    // 绘制三角形
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3);
    glBindVertexArray(0);
}
```

可以看到,uniform对于设置一个在渲染迭代中会改变的属性是一个非常有用的工具,它也是一个在程序和着色器间数据交互的很好工具。

# 更多顶点属性

我们把颜色数据加进顶点数据中,把三角形的三个角分别指定为红色、绿色和蓝色:

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

此时,需要调整一下顶点着色器。需要注意的是我们用layout标识符来把color属性的位置值设置为1:

```
#version 330 core
layout (location = 0) in vec3 position; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 color;    // 颜色变量的属性位置值为 1

out vec3 ourColor; // 向片段着色器输出一个颜色

void main()
{
    gl_Position = vec4(position, 1.0);
    ourColor = color; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}
```

相应的片段着色器:

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

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

我们需要重新配置顶点属性指针。更新后的VBO内存中的数据现在看起来像这样:

!(data/attachment/forum/202405/11/170747idn0ujjewwkncnk5.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

使用glVertexAttribPointer函数更新顶点格式:

```
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)0);
glEnableVertexAttribArray(0);
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), (GLvoid*)(3* sizeof(GLfloat)));
glEnableVertexAttribArray(1);
```

此时,步长为6 * sizeof(GLfloat),颜色属性的偏移量为3* sizeof(GLfloat)。

渲染结果:

!(data/attachment/forum/202405/11/172248pk3j3j663e6vte3i.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

注意,片段着色器中进行了片段插值(Fragment Interpolation)。当渲染一个三角形时,光栅化(Rasterization)阶段通常会生成比原指定顶点更多的片段(可以理解为像素?)。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。基于这些位置,它会插值(Interpolate)所有片段着色器的输入变量。比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝 + 70%绿。

# 自己的着色器类

为了方便着色器的管理,我们可以写一个类,从硬盘读取着色器,然后编译并链接它们,并进行错误检测。

我们会把着色器类全部放在在头文件里。先来添加必要的include,并定义类结构:

```
#ifndef SHADER_H
#define SHADER_H

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

#include <GL/glew.h>; // 包含glew来获取所有的必须OpenGL头文件

class Shader
{
public:
    // 程序ID
    GLuint Program;
    // 构造器读取并构建着色器
    Shader(const GLchar* vertexPath, const GLchar* fragmentPath);
    // 使用程序
    void Use();
};

#endif
```

着色器类储存了着色器程序的ID。它的构造器需要顶点和片段着色器源代码的文件路径,这样我们就可以把源码的文本文件储存在硬盘上了。我们还添加了一个Use函数,它其实不那么重要,但是能够显示这个自造类如何让我们的生活变得轻松(虽然只有一点)。

在构造器中,我们使用C++文件流读取着色器内容,储存到几个string对象里:

```
Shader(const GLchar* vertexPath, const GLchar* fragmentPath)
{
    // 1. 从文件路径中获取顶点/片段着色器
    std::string vertexCode;
    std::string fragmentCode;
    std::ifstream vShaderFile;// 文件输入流对象,用来打开和读取顶点着色器文件
    std::ifstream fShaderFile;// 文件输入流对象,用来打开和读取片段着色器文件
    // 保证ifstream对象可以抛出异常:
    vShaderFile.exceptions(std::ifstream::badbit);
    fShaderFile.exceptions(std::ifstream::badbit);
    try
    {
      // 打开文件
      vShaderFile.open(vertexPath);
      fShaderFile.open(fragmentPath);
      std::stringstream vShaderStream, fShaderStream;
      // 读取文件的缓冲内容到流中
      vShaderStream << vShaderFile.rdbuf();
      fShaderStream << fShaderFile.rdbuf();   
      // 关闭文件
      vShaderFile.close();
      fShaderFile.close();
      // 转换流至GLchar数组
      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();
    [...]
```

接下来,我们需要编译和链接着色器。注意,我们也将检查编译/链接是否失败:

```
// 2. 编译着色器
GLuint vertex, fragment;
GLint success;
GLchar infoLog;

// 顶点着色器
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;
};

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

// 着色器程序
this->Program = glCreateProgram();// this是一个指向当前对象的指针,用于在类的成员函数内部访问类的其他成员(变量和函数)
glAttachShader(this->Program, vertex);
glAttachShader(this->Program, fragment);
glLinkProgram(this->Program);
// 打印连接错误(如果有的话)
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;
}

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

实现Use函数:

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

如何使用这个类?

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

# 练习

1.修改顶点着色器让三角形上下颠倒。

!(data/attachment/forum/202405/11/211324ywd219e12ue25225.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

!(data/attachment/forum/202405/11/211352yvqtq6togph2p2xz.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

答案如下:

```
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 color;

out vec3 ourColor;

void main()
{
    gl_Position = vec4(position.x, - position.y, position.z, 1.0f);
    ourColor = color;
}
```

2.使用uniform定义一个水平偏移量,在顶点着色器中使用这个偏移量把三角形移动到屏幕右侧。

!(data/attachment/forum/202405/11/213756zttu7dflt0tict6r.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

参考答案在类内又实现了一个函数:

```
// In your CPP file:
// ======================
float offset = 0.5f;
ourShader.setFloat("xOffset", offset);
```

3.使用out关键字把顶点位置输出到片段着色器,并将片段的颜色设置为与顶点位置相等。做完这些后,尝试回答下面的问题:为什么在三角形的左下角是黑的?

!(data/attachment/forum/202405/11/214242st36datfqk3pazp8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/300 "image.png")

因为左下角的坐标是(-0.5,-0.5,0),作为颜色的话,R和G都是负数,可能被截断成0了,B也是0,所以最终就是黑色的。参考答案:Since the xy values are negative they are clamped to a value of 0.0f. This happens all the way to the center sides(边的中心) of the triangle since from that point on the values will be interpolated positively again(就是说开始变成正值了).
页: [1]
查看完整版本: 学习笔记3——OpenGL着色器(shader)