文章目录
- GLSL
- 数据类型
- 输入与输出
- Uniform的使用
- Shader类封装
- 练习0
- 练习1
- 练习2
- 练习3
前面的文章提到,着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分运行。从本质上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。
渲染着色原理参考:GAMES101学习笔记(四):Shading 着色
在这里推荐:ShaderToy 在这个网站,我们只需要关注着色器怎么写,而不需要关注其他渲染流程。
GLSL
着色器是使用一种类C语言的着色器语言GLSL(OpenGL Shading Language)写的。GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。
着色器的开头总是要声明版本,接着是输入和输出变量、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;void main()
{// 处理输入并进行一些图形操作...// 输出处理过的结果到输出变量out_variable_name = weird_stuff_we_processed;
}
当我们特别谈论到顶点着色器的时候,每个输入变量也叫顶点属性(Vertex Attribute)。我们能声明的顶点属性是有上限的,它一般由硬件来决定。OpenGL确保至少有16个包含4分量的顶点属性可用,但是有些硬件或许允许更多的顶点属性,你可以查询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、uint和bool。
GLSL也有两种容器类型,分别是向量(Vector)和矩阵(Matrix)。
在这里我们先介绍向量:
vecn
:包含n
个float
分量的默认向量bvecn
:包含n
个bool
分量的向量ivecn
:包含n
个int
分量的向量uvecn
:包含n
个unsigned int
分量的向量dvecn
:包含n
个double
分量的向量
大多数时候我们使用vecn
,因为float足够满足大多数要求了。
一个向量的分量可以通过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向量中去获取.z元素。
我们也可以把一个向量作为一个参数传给不同的向量构造函数,以减少需求参数的数量:
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关键字专门来实现每个着色器输入和输出,只要一个输出变量与下一个着色器阶段的输入匹配,这样就能进行数据交流和传递。
顶点着色器
顶点着色器应该接收的是一种特殊形式的输入,否则就会效率低下。顶点着色器的输入特殊在,输入是直接从顶点数据中获取的。为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。即layout (location = 0)
。顶点着色器需要为它的输入提供一个额外的layout标识,这样我们才能把它链接到顶点数据。
也可以忽略layout (location = 0)标识符,通过在OpenGL代码中使用
glGetAttribLocation
查询属性位置值(Location),但是更建议在着色器中设置它们,这样会更容易理解而且节省你(和OpenGL)的工作量。
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0out vec4 vertexColor; // 为片段着色器指定一个颜色输出void main()
{gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
片段着色器
片段着色器需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终输出的颜色。如果你在片段着色器没有定义输出颜色,OpenGL会把你的物体渲染为黑色(或白色)。
所以,如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量链接到一起,它们之间就能发送数据了(这是在链接程序对象时完成的)。
#version 330 core
out vec4 FragColor;in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)void main()
{FragColor = vertexColor;
}
我们在顶点着色器中声明了一个vertexColor变量作为vec4输出,并在片段着色器中声明了一个类似的vertexColor。由于它们名字相同且类型相同,片段着色器中的vertexColor就和顶点着色器中的vertexColor链接了。由于我们在顶点着色器中将颜色设置为深红色,最终的片段也是深红色的。
Uniform的使用
Uniform
是另一种从我们的应用程序在 CPU 上传递数据到 GPU 上的着色器的方式,但uniform和顶点属性有些不同。
- uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
- 无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。
要在 GLSL 中声明 uniform,我们只需在着色器中使用 uniform 关键字,并带上类型和名称。从那时起,我们就可以在着色器中使用新声明的 uniform。我们来看看这次是否能通过uniform设置三角形的颜色:
#version 330 core
out vec4 FragColor;uniform vec4 ourColor; // 在OpenGL程序代码中设定这个变量void main()
{FragColor = ourColor;
}
我们在片段着色器中声明了一个uniform vec4
的ourColor
,并把片段着色器的输出颜色设置为ourColor
值的内容。因为uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。顶点着色器中不需要这个uniform,所以我们不用在那里定义它。
如果你声明了一个uniform却在GLSL代码中没用过,编译器会静默移除这个变量,导致最后编译出的版本中并不会包含它,这可能导致非常麻烦的错误
- 首先需要找到着色器中
uniform
属性的索引/位置值。 - 当我们得到
uniform
的索引/位置值后,我们就可以更新它的值了。
这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:
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
查询uniform ourColor
的位置值。我们需要提供着色器程序和uniform变量的名字(这是我们希望获得的位置值的来源)。
如果glGetUniformLocation
返回-1
就代表没有找到这个位置值。 - 最后,我们可以通过
glUniform4f
函数设置uniform
值。注意,查询uniform地址不要求你之前使用过着色器程序,但是更新一个uniform之前你必须先使用程序(调用
glUseProgram
),因为它是在当前激活的着色器程序中设置uniform的。
因为OpenGL在其核心是一个C库,所以它不支持类型重载
在函数参数不同的时候就要为其定义新的函数;glUniform
是一个典型例子。这个函数有一个特定的后缀,标识设定的uniform的类型。可能的后缀有:
后缀 | 含义 |
---|---|
f | 函数需要一个float作为它的值 |
i | 函数需要一个int作为它的值 |
ui | 函数需要一个unsigned int作为它的值 |
3f | 函数需要3个float作为它的值 |
fv | 函数需要一个float向量/数组作为它的值 |
每当配置一个OpenGL的选项时就要选择适合数据类型的函数。在上面的例子里,我们希望分别设定uniform
的4个float
值,所以我们通过glUniform4f
传递我们的数据(也可以使用fv版本)。
要实现理想的效果(三角形的颜色随时间变换),那么我们要在渲染循环中的每一次迭代中都实时更新uniform
: 完整源码:参考
while(!glfwWindowShouldClose(window))
{// 输入processInput(window);// 渲染// 清除颜色缓冲glClearColor(0.2f, 0.3f, 0.3f, 1.0f);glClear(GL_COLOR_BUFFER_BIT);// 记得激活着色器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);// 绘制三角形glBindVertexArray(VAO);glDrawArrays(GL_TRIANGLES, 0, 3);// 交换缓冲并查询IO事件glfwSwapBuffers(window);glfwPollEvents();
}
Shader类封装
编写、编译、管理着色器是件麻烦事,我们将封装目前所学的知识到一个抽象对象中。
shader类的基本定义:
#ifndef SHADER_H
#define SHADER_H#include <glad/glad.h>
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>class Shader
{
public:// 程序IDunsigned 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;
};#endif
构造函数从传入的文件路径读取我们写好的glsl源码文件,构建顶点着色器和片段着色器。
首先,使用C++文件流读取着色器内容,储存到几个string对象里:
Shader(const char* vertexPath, const char* fragmentPath)
{// 1. 从文件路径中获取顶点/片段着色器std::string vertexCode;std::string fragmentCode;std::ifstream vShaderFile;std::ifstream fShaderFile;// 保证ifstream对象可以抛出异常:vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);fShaderFile.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();// 转换数据流到stringvertexCode = 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. 编译着色器[...]
}
下一步,我们需要编译和链接着色器。
Shader(const char* vertexPath, const char* fragmentPath)
{// 1. 从文件路径中获取顶点/片段着色器[...]// 2. 编译着色器unsigned int vertex, fragment;// vertex shadervertex = glCreateShader(GL_VERTEX_SHADER);glShaderSource(vertex, 1, &vShaderCode, NULL);glCompileShader(vertex);checkCompileErrors(vertex, "VERTEX");// fragment Shaderfragment = glCreateShader(GL_FRAGMENT_SHADER);glShaderSource(fragment, 1, &fShaderCode, NULL);glCompileShader(fragment);checkCompileErrors(fragment, "FRAGMENT");// shader ProgramID = glCreateProgram();glAttachShader(ID, vertex);glAttachShader(ID, fragment);glLinkProgram(ID);checkCompileErrors(ID, "PROGRAM");// 删除着色器,它们已经链接到我们的程序中了,已经不再需要glDeleteShader(vertex);glDeleteShader(fragment);
}
use
函数非常简单,即按ID来使用链接好的程序
void use()
{ glUseProgram(ID);
}
uniform
的setter
函数也类似:
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 ourShader("path/shader.vs", "path/shader.fs");
...
while(...)
{ourShader.use();ourShader.setFloat("someUniform", 1.0f);DrawStuff();
}
练习0
现在我们了解了如何填充VBO、配置顶点属性指针以及如何把它们都储存到一个VAO里。
这次,我们同样打算把颜色数据加进顶点数据中。我们将把颜色数据添加为3个float
值至vertices
数组。我们将把三角形的三个角分别指定为红色、绿色、蓝色:
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 // 顶部
};
由于现在有更多的数据要发送到顶点着色器,我们有必要去调整一下顶点着色器:
使它能够接收颜色值作为一个顶点属性输入
需要注意的是我们用layout
标识符来把aColor
属性的位置值设置为1
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1out vec3 ourColor; // 向片段着色器输出一个颜色void main()
{gl_Position = vec4(aPos, 1.0);ourColor = aColor; // 将ourColor设置为我们从顶点数据那里得到的输入颜色
}
由于我们不再使用uniform来传递片段的颜色了,现在使用ourColor
输出变量,我们必须再修改一下片段着色器:
#version 330 core
out vec4 FragColor;
in vec3 ourColor;void main()
{FragColor = vec4(ourColor, 1.0);
}
因为我们添加了另一个顶点属性,并且更新了VBO的内存,我们就必须重新配置顶点属性指针。更新后的VBO内存中的数据现在看起来像这样:
知道了现在使用的布局,我们就可以使用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);
我们在上一节已经详细了解过这两个api函数,在这里再回顾一下:
使用glVertexAttribPointer
函数告诉OpenGL该如何解析顶点数据(应用到逐个顶点属性上)
- 第一个参数指定我们要配置的顶点属性。在顶点着色器中使用
layout(location = 0)
定义了position
顶点属性的位置值Location为0- 第二个参数指定顶点属性的大小
- 第三个参数指定数据的类型,这里是
GL_FLOAT
- 第四个参数定义我们是否希望数据被标准化(Normalize),
GL_TRUE
,GL_FALSE
- 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。
- 最后一个参数的类型是
void*
,表示位置数据在缓冲中起始位置的偏移量(Offset)
使用
glEnableVertexAttribArray
以顶点属性位置值作为参数,启用顶点属性
由于我们现在有了两个顶点属性,我们要重新计算步长值。为获得数据队列中下一个属性值(比如位置向量的下个x分量)我们必须向右移动6个float
,其中3个是位置值,另外3个是颜色值。这使我们的步长值为6乘以float
的字节数(=24字节)。
同样,这次我们必须指定一个偏移量。对于每个顶点来说:
- 位置顶点属性在前,所以它的偏移量是
0
- 颜色属性紧随位置数据之后,所以偏移量就是
3 * sizeof(float)
= 12字节
运行程序会看到如下结果:完整源码参考
图中呈现的效果,是在片段着色器中进行的所谓片段插值(Fragment Interpolation)的结果。
当渲染一个三角形时,光栅化(Rasterization)阶段通常会造成比原指定顶点更多的片段。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。
基于这些位置,它会插值(Interpolate)所有片段着色器的输入变量。比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合(更精确地说就是30%蓝 + 70%绿)
这正是在这个三角形中发生了什么。我们有3个顶点,和相应的3个颜色,从这个三角形的像素来看它可能包含50000左右的片段,片段着色器为这些像素进行插值颜色。如果你仔细看这些颜色就应该能明白了:红首先变成到紫再变为蓝色。片段插值会被应用到片段着色器的所有输入属性上。
练习1
修改顶点着色器让三角形上下颠倒:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;out vec3 ourColor;void main()
{// 只需在y坐标前添加符号gl_Position = vec4(aPos.x, -aPos.y, aPos.z, 1.0); ourColor = aColor;
}
练习2
使用uniform定义一个水平偏移量,在顶点着色器中使用这个偏移量把三角形移动到屏幕右侧:
修改顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;out vec3 ourColor;
uniform float xOffset;void main()
{gl_Position = vec4(aPos.x + xOffset, aPos.y, aPos.z, 1.0); // add the xOffset to the x position of the vertex positionourColor = aColor;
}
渲染循环中设置offset值:
float offset = 0.5f;
ourShader.setFloat("xOffset", offset);
练习3
使用out关键字把顶点位置输出到片段着色器,并将片段的颜色设置为与顶点位置相等(来看看连顶点位置值都在三角形中被插值的结果)。
修改顶点着色器:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;// out vec3 ourColor;
out vec3 ourPosition;void main()
{gl_Position = vec4(aPos, 1.0); // ourColor = aColor;ourPosition = aPos;
}
修改片段着色器:
#version 330 core
out vec4 FragColor;
// in vec3 ourColor;
in vec3 ourPosition;void main()
{FragColor = vec4(ourPosition, 1.0); // note how the position value is linearly interpolated to get all the different colors
}
尝试回答问题:为什么在三角形的左下角是黑的?
Think about this for a second: the output of our fragment’s color is equal to the (interpolated) coordinate of the triangle. What is the coordinate of the bottom-left point of our triangle? This is (-0.5f, -0.5f, 0.0f). 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. Values of 0.0f are of course blackand that explains the black side of the triangle.
我们将顶点坐标输出到片段着色器,左下角的坐标为(-0.5f, -0.5f, 0.0f)
,xy为负值,它会被限制在0,所以最终的颜色为黑色(0.0f, 0.0f, 0.0f)