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中有基础数据类型:int
、float
、double
、uint
和bool
;也有两种容器类型:向量(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定义了in
和out
关键字专门来实现这个目的。每个着色器使用这两个关键字设定输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去。
但在顶点和片段着色器中会有点不同:
顶点着色器的输入很特殊,它直接接收顶点数据的输入。因此需要使用
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)