4 - 了解着色器

前面只是简单的使用了下着色器,本文会用一种更加广泛的形式详细解释着色器,特别是OpenGL着色器语言(GLSL)。

GLSL

着色器是使用一种叫GLSL的类C语言写成的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性,代码风格比较像C语言。

典型的着色器有下面的结构:

// 声明版本
#version version_number
// 输入变量
in type in_var1;
// 输出变量
out type out_var1;
// uniform
uniform type uniform_name;

// main函数
int main()
{
    // 处理输入变量并进行一些图形操作
    ...
    // 输出处理过的结果到输出变量
    out_var1 = xxxx;
}

不同类型的着色器有着不同的限制。以顶点着色器为例,每个输入变量也叫做顶点属性(Vertex Attribute),能够声明的顶点属性是有上限的,由硬件来决定。OpenGL确保至少有16个4分量的顶点属性可用,可以通过以下代码查询:

int nrAttributes;
glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &nrAttributes);
std::cout << "Maximum nr of vertex attributes supported: " << nrAttributes << std::endl;

数据类型

GLSL中有基础数据类型:intfloatdoubleuintbool;也有两种容器类型:向量(Vector)和矩阵(Matrix)。

向量

是一个可以包含2~4个分量的容器,分量的类型可以是前面基础类型的任意一个(n代表分量的数量):

类型含义
vecn包含n个float分量的默认向量
bvecn包含n个bool分量的向量
ivecn包含n个int分量的向量
uvecn包含n个unsigned int分量的向量
dvecn包含n个double分量的向量

大多数情况用第一个就够了,使用分量可以用.x/.y/.z/.w来获取。

除此之外,还有一个叫做 重组(Swizzling) 的语法,支持更灵活的分量选择:

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec4 otherVec = someVec.xxxx + differentVec.wzyx

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

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

输入与输出

每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL定义了inout关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。

但在顶点和片段着色器中会有点不同:

  • 顶点着色器的输入很特殊,它直接接收顶点数据的输入。因此需要使用layout (location = 0)来把它链接到顶点数据。

    #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); // 把输出变量设置为暗红色
    }
  • 片段着色器的输出很特殊,它需要一个vec4颜色输出变量,否则OpenGL会把你的物体渲染为黑色(或白色)。

    #version 330 core
    out vec4 FragColor;
    
    in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
    
    void main()
    {
        FragColor = vertexColor;
    }

除此之外还得注意一下,看看上边是如何进行 着色器数据通信 的(在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入)。

Uniform

Uniform是一种从CPU中的应用向GPU中的着色器发送数据的方式,但uniform和顶点属性有些不同:

  • uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
  • uniform生命周期很长,数据会被一直保存,直到被重置。

可以在片段着色器中声明一个uniform变量,用于从代码层面上设置三角形的颜色:

#version 330 core
out vec4 FragColor;

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

void main()
{
    FragColor = ourColor;
}

PS:如果声明了uniform变量却在glsl中没用过,编译器会移除它,导致一些错误出现

然后就能通过代码给这个变量添加数据了:

// 激活着色器
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);

激活着色器后,通过glGetUniformLocation()获取到uniform变量的位置(没找到就返回-1),通过glUniform4f()更新该uniform变量的值。结果如下,颜色随时间变化而变化:

PS:设置不同类型的uniform变量时,所需的glUniformxx()后缀也不同,如下表

后缀含义
nf函数需要n个float作为它的值
ni函数需要n个int作为它的值
nui函数需要n个unsigned int作为它的值
nfv函数需要n个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    // 顶部
};

接下来,我们调整顶点着色器,引入颜色变量:

#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设置为我们从顶点数据那里得到的输入颜色
}

从上边可以看到,ourColor不再是uniform变量了,因此也改一下片段着色器:

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

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

新的顶点属性应该是这样子的:

接下来就按照上边的布局使用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);

我们只定义了三个顶点的位置和颜色,为什么会出现彩色渐变?这是在片段着色器中进行片段插值(Fragment Interpolation)的结果。

自定义着色器类

写一个着色器管理类来让我们的生活轻松一点,它可以从硬盘读取着色器,然后编译并链接它们,并对它们进行错误检测。

头文件如下:

#pragma once

#ifndef MY_SHADER_H
#define MY_SHADER_H

#include <glad/glad.h>

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


class Shader
{
public:
	// 程序ID
	unsigned int ID;

	// 构建着色器的构造函数
	Shader(const char* vertexPath, const char* fragmentPath);

	// 使用/激活着色器程序
	void use();

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

private:

};

#endif // !MY_SHADER_H

.cpp文件如下:

#include "shader.h"

Shader::Shader(const char* vertexPath, const char* fragmentPath)
{
	// 1.从文件路径中获取顶点&片段着色器
	std::string vertexCode, fragmentCode;
	std::ifstream vShaderFile, fShaderFile;
	// 确保ifstream对象可以抛出异常
	vShaderFile.exceptions(std::ifstream::failbit | std::ifstream::badbit);
	sShaderFile.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();
		// 转换数据流为string
		vertexCode = vShaderStream.str();
		fragmentCode = fShaderStream.str();
	}
	catch (std::ifstream::failure e)
	{
		std::cout << "ERROR::SHADER::FILE_NOT_SUCCESFULLY_READ" << std::endl;
        std::cout << e.what() << std::endl;
	}
	// 转化为c风格字符串
	const char* vShaderCode = vertexCode.c_str();
	const char* fShaderCode = fragmentCode.c_str();


	// 2.编译着色器
	unsigned int 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;
	}

	// 片段着色器
	fragment = glCreateShader(GL_FRAGMENT_SHADER);
	glShaderSource(fragment, 1, &fShaderCode, NULL);
	glCompileShader(fragment);
	glGetShaderiv(fragment, GL_COMPILE_STATUS, &success);
	if (!success)
	{
		glGetShaderInfoLog(fragment, 512, NULL, infoLog);
		std::cout << "ERROR::SHADER::FRAGMENT::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);
}

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

void Shader::setBool(const std::string& name, bool value) const
{
	glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void Shader::setInt(const std::string& name, int value) const
{
	glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
void Shader::setFloat(const std::string& name, float value) const
{
	glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
}

可以这样子使用:

#include "shader.h"
...
int main()
{
    ...
    // 自己的着色器类
    Shader ourShader("./shader/shader.vs", "./shader/shader.fs");
    ...
    while(...)
    {
        // 激活着色器
        ourShader.use();
        ourShader.setFLoat("floatUniform", 1,0f);
        // 画三角形
        ...
    }
}

课后练习

练习1

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

只需将gl_Position的y值改为相反数即可:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

out vec3 ourColor;

void main()
{
   gl_Position = vec4(aPos.x, -aPos.y, aPos.z, 1.0);
   ourColor = aColor;
}

练习2

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

在顶点着色器中,添加float类型的uniform变量,并修改gl_Position:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

out vec3 ourColor;

uniform float offset;

void main()
{
   gl_Position = vec4(aPos.x + offset, aPos.yz, 1.0);
   ourColor = aColor;
}

在main.cpp中,对这个uniform变量进行设置即可:

// 激活着色器
ourShader.use();
ourShader.setFloat("offset", 0.5f);

练习3

使用out关键字把顶点位置输出到片段着色器,并将片段的颜色设置为与顶点位置相等(来看看连顶点位置值都在三角形中被插值的结果)。做完这些后,尝试回答下面的问题:为什么在三角形的左下角是黑的?

按要求做完后,发现左下角是黑的:

这是因为左下角的颜色值是(-0.5, -0.5, 0),转换成rgb后就成了(0, 0, 0)即黑色,然后通过插值将其周围也变黑了。

参考资料

  • 着色器 - LearnOpenGL CN (learnopengl-cn.github.io)