【图形学】Learn OpenGL笔记
聪头 游戏开发萌新

Learn OpenGL

时间:2023年3月24日17:01:20

中文版地址:https://learnopengl-cn.github.io/

说明:

  • 查找文档介绍的特定函数可以这样搜索:==函数名==
  • shader模板位置:C:\Users\13220\Documents\Visual Studio 2019
    • 导出模板:项目->导出模板
  • 英文版的地址为:https://learnopengl.com/

其他优质链接:

简介

规定

为了让教程更容易理解,结构更鲜明,本站采用了方框代码块

image image

Ch1.入门

1.1 OpenGL

https://learnopengl-cn.github.io/01%20Getting%20started/01%20OpenGL/

OpenGL是什么

一般它被认为是一个==API==(Application Programming Interface, 应用程序编程接口),包含了一系列可以操作图形、图像的函数。

然而,OpenGL本身并不是一个API,它仅仅是一个由Khronos组织制定并维护的==规范==(Specification)。


==OpenGL规范==严格规定了每个函数该如何执行,以及它们的输出值。至于内部具体每个函数是如何实现(Implement)的,将==由OpenGL库的开发者自行决定==(译注:这里开发者是指编写OpenGL库的人)。

实际的==OpenGL库的开发者==通常是==显卡的生产商==。

所有版本的OpenGL规范文档都被公开的寄存在Khronos那里。有兴趣的读者可以找到OpenGL3.3(我们将要使用的版本)的规范文档。如果你想深入到OpenGL的细节(只关心函数功能的描述而不是函数的实现),这是个很好的选择。

立即渲染模式与核心模式

立即渲染模式

  • 早期的OpenGL使用立即渲染模式(Immediate mode,也就是固定渲染管线),这个模式下绘制图形很方便。OpenGL的大多数功能都被库隐藏起来,开发者很少有控制OpenGL如何进行计算的自由。
  • 立即渲染模式确实容易使用和理解,但是效率太低。因此从OpenGL3.2开始,规范文档开始废弃立即渲染模式,并鼓励开发者在OpenGL的核心模式(Core-profile)下进行开发,这个分支的规范完全移除了旧的特性。

核心模式

  • 当使用OpenGL的核心模式时,OpenGL迫使我们使用现代的函数。
  • 现代函数的优势是更高的灵活性和效率,然而也更难于学习。立即渲染模式从OpenGL实际运作中抽象掉了很多细节,因此它在易于学习的同时,也很难让人去把握OpenGL具体是如何运作的。
  • 它有一些难度,然而提供了更多的灵活性,更高的效率,更重要的是可以更深入的理解图形编程。

扩展

  • OpenGL的一大特性就是对扩展(Extension)的支持,当一个显卡公司提出一个新特性或者渲染上的大优化,通常会以扩展的方式在驱动中实现。
  • 开发者不必等待一个新的OpenGL规范面世,就可以使用这些新的渲染特性了,只需要简单地检查一下显卡是否支持此扩展。
  • 使用扩展的代码大多看上去如下:
1
2
3
4
5
6
7
8
if(GL_ARB_extension_name)
{
// 使用硬件支持的全新的现代特性
}
else
{
// 不支持此扩展: 用旧的方式去做
}

状态机

OpenGL自身是一个巨大的==状态机==(State Machine):一系列的变量描述OpenGL此刻应当如何运行。

OpenGL的状态通常被称为OpenGL==上下文==(Context)。

我们通常使用如下途径去更改OpenGL状态:设置选项,操作缓冲。最后,我们使用当前OpenGL上下文来渲染。

当使用OpenGL的时候,我们会遇到一些==状态设置函数==(State-changing Function),这类函数将会改变上下文。

以及==状态使用函数==(State-using Function),这类函数会根据当前OpenGL的状态执行一些操作。

对象

OpenGL库是用C语言写的

在OpenGL中一个对象是指一些选项的集合,它代表OpenGL状态的一个子集。

  • 比如,我们可以用一个对象来代表绘图窗口的设置,之后我们就可以设置它的大小、支持的颜色位数等等。可以把对象看做一个C风格的结构体(Struct):
1
2
3
4
5
struct object_name {
float option1;
int option2;
char[] name;
};

在更新前的教程中一直使用的都是==OpenGL的基本类型==,但由于作者觉得在本教程系列中并没有一个必须使用它们的原因,所有的类型都改为了==自带类型==。但是请仍然记住,==使用OpenGL的类型==的==好处==是保证了在各平台中每一种类型的大小都是统一的。


当我们使用一个对象时,通常看起来像如下一样(把OpenGL上下文看作一个大的结构体):

1
2
3
4
5
6
// OpenGL的状态
struct OpenGL_Context {
...
object* object_Window_Target;
...
};

下述过程类似“批处理”

1
2
3
4
5
6
7
8
9
10
// 创建对象
unsigned int objectId = 0;
glGenObject(1, &objectId);
// 绑定对象至上下文
glBindObject(GL_WINDOW_TARGET, objectId);
// 设置当前绑定到 GL_WINDOW_TARGET 的对象的一些选项
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_WIDTH, 800);
glSetObjectOption(GL_WINDOW_TARGET, GL_OPTION_WINDOW_HEIGHT, 600);
// 将上下文对象设回默认
glBindObject(GL_WINDOW_TARGET, 0);

解释:这一小段代码展现了你以后使用OpenGL时常见的工作流。

  1. 我们首先创建一个对象,然后用一个id保存它的引用(实际数据被储存在后台)。
  2. 然后我们将对象绑定至上下文的目标位置(例子中窗口对象目标的位置被定义成GL_WINDOW_TARGET)。
  3. 接下来我们设置窗口的选项。
  4. 最后我们将目标位置的对象id设回0,解绑这个对象。设置的选项将被保存在objectId所引用的对象中,一旦我们重新绑定这个对象到GL_WINDOW_TARGET位置,这些选项就会重新生效。

好处:

  • 使用对象的一个好处是在程序中,我们不止可以定义一个对象,并设置它们的选项,每个对象都可以是不同的设置。在我们执行一个使用OpenGL状态的操作的时候,只需要绑定含有需要的设置的对象即可。
    • 比如说我们有一些作为3D模型数据(一栋房子或一个人物)的容器对象,在我们想绘制其中任何一个模型的时候,只需==绑定一个包含对应模型数据的对象==就可以了(当然,我们需要先创建并设置对象的选项)。拥有数个这样的对象允许我们指定多个模型,在想画其中任何一个的时候,直接将对应的对象绑定上去,便不需要再重复设置选项了。

1.2 创建窗口

https://learnopengl-cn.github.io/01%20Getting%20started/02%20Creating%20a%20window/

在我们画出出色的效果之前,首先要做的就是创建一个==OpenGL上下文==(Context)和一个==用于显示的窗口==。然而,这些操作在每个系统上都是不一样的,OpenGL有意将这些操作抽象(Abstract)出去。这意味着我们不得不自己处理创建窗口,定义OpenGL上下文以及处理用户输入。

GLFW

==作用==:对常见OpenGL规范定义函数的实现(猜测)

GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入,对我们来说这就够了。

GLAD

==作用==:使得程序能够直接调用OpenGL规范定义的函数,而避免运行时手动查询

OpenGL只是一个标准/规范,具体的实现是由驱动开发商针对特定显卡实现的。由于OpenGL驱动版本众多,它大多数函数的位置都无法在编译时确定下来,需要在运行时查询。所以任务就落在了开发者身上,==开发者需要在运行时获取函数地址==并将其保存在一个函数指针中供以后使用。取得地址的方法因平台而异,在Windows上会是类似这样:

1
2
3
4
5
6
7
// 定义函数原型
typedef void (*GL_GENBUFFERS) (GLsizei, GLuint*);
// 找到正确的函数并赋值给函数指针
GL_GENBUFFERS glGenBuffers = (GL_GENBUFFERS)wglGetProcAddress("glGenBuffers");
// 现在函数可以被正常调用了
GLuint buffer;
glGenBuffers(1, &buffer);

你可以看到代码非常复杂,而且很繁琐,我们需要对每个可能使用的函数都要重复这个过程。幸运的是,有些库能简化此过程,其中GLAD是目前最新,也是最流行的库。

OpenGL之glut、glfw、glew、glad等库之间的关系

参考链接:https://blog.51cto.com/u_15295315/3042717

结论
1.glad与glew作用类似,实现对底层OpenGL接口封装

2.glfw与glut作用类似,创建窗口界面

3.glut年代久远,现在用glfw居多,可使用glfw+glad组合方式

image

环境配置(略)

环境配置详见该节文档

image

1.3 你好,窗口

Window creation hints 文档:https://www.glfw.org/docs/latest/window.html#window_hints

image

1.4 你好,三角形

https://learnopengl-cn.github.io/01%20Getting%20started/04%20Hello%20Triangle/

概念

三个重要对象

顶点数组对象:Vertex Array Object,VAO

顶点缓冲对象:Vertex Buffer Object,VBO

元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object, IBO

图形渲染管线 Graphics Pipline

3D坐标转为2D坐标的处理过程是由OpenGL的==图形渲染管线==(Graphics Pipeline),图形渲染管线可以被划分为两个主要部分:

  • 第一部分把你的3D坐标转换为2D坐标
  • 第二部分是把2D坐标转变为实际的有颜色的像素

2D坐标和像素也是不同的,2D坐标精确表示一个点在2D空间中的位置,而2D像素是这个点的近似值,2D像素受到你的屏幕/窗口分辨率的限制。

综上,图形渲染管线接受一组3D坐标,然后把它们转变为你屏幕上的有色2D像素输出。

着色器 Shader

图形渲染管线可以被划分为几个阶段,每个阶段将会把前一个阶段的输出作为输入。所有这些阶段都是高度专门化的(它们都有一个特定的函数),并且很容易并行执行

正是由于它们具有并行执行的特性,当今大多数显卡都有成千上万的小处理核心,它们在GPU上为每一个(渲染管线)阶段运行各自的小程序,从而在图形渲染管线中快速处理你的数据。这些小程序叫做==着色器==(Shader)。

OpenGL着色器是用==OpenGL着色器语言==(OpenGL Shading Language, GLSL)写成的

解释图形渲染管线
image
1.顶点数据(Vertex Data)
  • 我们以数组的形式传递3个3D坐标作为图形渲染管线的输入,用来表示一个三角形,这个数组叫做顶点数据
  • 顶点数据是一系列顶点的集合
  • 一个顶点(Vertex)是一个3D坐标的数据的集合
  • 顶点数据是用顶点属性(Vertex Attribute)表示的,它可以包含任何我们想用的数据

本例中为了简单起见,假定每个顶点只由一个3D位置和一些颜色值组成

  • 图元:为了让OpenGL知道我们的坐标和颜色值构成的到底是什么,OpenGL需要你去指定这些数据所表示的渲染类型。做出的这些提示叫做==图元==(Primitive),任何一个绘制指令的调用都将把图元传递给OpenGL。这是其中的几个:GL_POINTSGL_TRIANGLESGL_LINE_STRIP

2.==顶点着色器(Vertex Shader)==

  • 图形渲染管线的第一个部分是顶点着色器(Vertex Shader),它把一个单独的顶点作为输入
  • 目的:把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。

3.图元装配(Primitive Assembly)

  • 图元装配(Primitive Assembly)阶段将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状
  • 本节例子中是一个三角形。

4.==几何着色器(Geometry Shader)==

  • 图元装配阶段的输出会传递给几何着色器(Geometry Shader)。几何着色器把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状
  • 例子中,它生成了另一个三角形。

5.光栅化阶段(Rasterization Stage)
  • 几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的==片段==(Fragment)。
    • OpenGL中的一个==片段==是OpenGL渲染一个像素所需的所有数据。
  • 补充:在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

6.==片段着色器(Fragment Shader)==

  • 目的:计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。
    • 通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。

7.Alpha测试和混合(Blending)

  • 这个阶段检测片段的对应的==深度(和模板(Stencil))值==,用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃
  • 这个阶段也会检查==alpha值==(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。

小结

  • 可以看到,图形渲染管线非常复杂,它包含很多可配置的部分。然而,对于大多数场合,我们只需要配置==顶点和片段着色器==就行了。==几何着色器是可选的==,通常使用它默认的着色器就行了。
  • 在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)

一个简单的渲染流程

1)顶点输入

开始绘制图形之前,我们需要先给OpenGL输入一些顶点数据。

1
2
3
4
5
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
标准化设备坐标 NDC

Normalized Device Coordinates

OpenGL仅当3D坐标在3个轴(x、y和z)上==-1.0到1.0==的范围内时才处理它。所有在这个范围内的坐标叫做==标准化设备坐标==(Normalized Device Coordinates)。

顶点缓冲对象 VBO

Vertex Buffer Objects, VBO

==作用==:告知GPU创建显存存储顶点数据,并解释如何处理这块数据

引入:

  • 定义顶点数据以后,我们会把它作为输入发送给图形渲染管线的第一个处理阶段:顶点着色器。
  • 它会在GPU上==创建内存==用于储存我们的顶点数据,还要==配置OpenGL如何解释这些内存==,并且==指定其如何发送给显卡==。
  • 顶点着色器接着会==处理==我们在内存中指定数量的顶点。

大致流程:输入顶点数据给顶点着色器 -> 在GPU创建内存 -> 告诉CPU如何解释这块内存 -> CPU发送信号给GPU -> GPU处理这个块内存


我们通过==顶点缓冲对象==(Vertex Buffer Objects, VBO)管理这个内存,它会在GPU内存(通常被称为显存)中储存大量顶点。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。

顶点缓冲对象是我们在[OpenGL](https://learnopengl-cn.github.io/01 Getting started/01 OpenGL/)教程中第一个出现的OpenGL对象。就像OpenGL中的其它对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用glGenBuffers函数和一个缓冲ID生成一个VBO对象

1
2
3
4
5
6
7
unsigned int VBO;
glGenBuffers(1, &VBO);

//源码对应的函数原型,猜测参数
//参数①:对象个数
//参数②:修改VBO的值为相应对象的ID(从1开始分配),后面使用这个ID来得到指定对象
typedef void (APIENTRYP PFNGLGENBUFFERSPROC)(GLsizei n, GLuint *buffers);

OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:

1
2
3
4
glBindBuffer(GL_ARRAY_BUFFER, VBO);

//源码
typedef void (APIENTRYP PFNGLBINDBUFFERPROC)(GLenum target, GLuint buffer);

从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。


然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:

1
2
3
4
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

//源码
typedef void (APIENTRYP PFNGLBUFFERDATAPROC)(GLenum target, GLsizeiptr size, const void *data, GLenum usage);

==glBufferData==函数:

  • 功能:是一个专门用来把用户定义的数据==复制==到当前绑定缓冲的函数。
  • 第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上
  • 第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。
  • 第三个参数是我们希望发送的实际数据。
  • 第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
    • GL_STATIC_DRAW :数据不会或几乎不会改变。
    • GL_DYNAMIC_DRAW:数据会被改变很多。
    • GL_STREAM_DRAW :数据每次绘制时都会改变。
    • 补充:三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是GL_STATIC_DRAW。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是GL_DYNAMIC_DRAW或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。

2)顶点着色器

我们需要做的第一件事是用着色器语言GLSL(OpenGL Shading Language)==编写==顶点着色器,然后==编译==这个着色器,这样我们就可以在程序中使用它了。

1
2
3
4
5
6
7
8
9
10
11
#version 330 core //每个着色器都起始于一个版本声明。OpenGL 3.3以及和更高版本中,GLSL版本号和OpenGL的版本是匹配的。我们同样明确表示我们会使用核心模式。
//使用in关键字,在顶点着色器中声明所有的输入顶点属性(Input Vertex Attribute)
//GLSL有一个向量数据类型,它包含1到4个float分量,包含的数量可以从它的后缀数字看出来。
//通过layout (location = 0)设定了输入变量的位置值(Location)你后面会看到为什么我们会需要这个位置值。
layout (location = 0) in vec3 aPos;

void main()
{
//预定义的gl_Position变量,它在幕后是vec4类型的。
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}

当前这个顶点着色器可能是我们能想到的最简单的顶点着色器了,因为我们对输入数据什么都没有处理就把它传到着色器的输出了。在真实的程序里输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内。

3)编译着色器

将1.4.3顶点着色器的内容写成C风格字符串

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

为了能够让OpenGL使用它,我们必须在运行时动态编译它的源代码。


我们首先要做的是创建一个着色器对象,注意还是用ID来引用的

1
2
unsigned int vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER); //创建一个顶点着色器,传递的参数是GL_VERTEX_SHADER

此法不同于之前的glbindxxx,是将返回值而不是参数作为id


下一步我们把这个着色器源码附加到着色器对象上,然后编译它

1
2
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
glCompileShader(vertexShader);

==glShaderSource==:

  • 第一个参数:要编译的着色器对象
  • 第二个参数:指定了传递的源码字符串数量
  • 第三个参数:顶点着色器真正的源码
  • 第四个参数我们先设置为NULL

检查编译是否成功

1
2
3
int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);

success:表示是否成功编译

infoLog:储存错误消息(如果有的话)的容器

如果编译失败,我们会用glGetShaderInfoLog获取错误消息,然后打印它:

1
2
3
4
5
if(!success)
{
glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl;
}

4)片段着色器

片段着色器(Fragment Shader)是第二个也是最后一个我们打算创建的用于渲染三角形的着色器。片段着色器所做的是==计算像素最后的颜色输出==。为了让事情更简单,我们的片段着色器将会一直输出橘黄色。

在计算机图形中颜色被表示为有4个元素的数组:红色、绿色、蓝色和alpha(透明度)分量,通常缩写为RGBA。当在OpenGL或GLSL中定义一个颜色的时候,我们把颜色每个分量的强度设置在0.0到1.0之间。比如说我们设置红为1.0f,绿为1.0f,我们会得到两个颜色的混合色,即黄色。这三种颜色分量的不同调配可以生成超过1600万种不同的颜色!

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}

编译片段着色器的过程与顶点着色器类似,只不过我们使用GL_FRAGMENT_SHADER常量作为着色器类型:

1
2
3
4
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

两个着色器现在都编译了,剩下的事情是把两个着色器对象==链接==到一个用来渲染的着色器程序(Shader Program)中。

5)着色器程序

创建一个程序对象很简单:

1
2
3
4
5
6
unsigned int shaderProgram;
shaderProgram = glCreateProgram(); //glCreateProgram函数创建一个程序,并返回新创建程序对象的ID引用

glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
glLinkProgram(shaderProgram); //用glLinkProgram链接

检查编译是否成功

就像着色器的编译一样,我们也可以检测链接着色器程序是否失败,并获取相应的日志。与上面不同,我们不会调用glGetShaderiv和glGetShaderInfoLog,现在我们使用:

1
2
3
4
5
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
...
}

得到一个程序对象后,我们可以调用glUseProgram函数,用刚创建的程序对象作为它的参数,以==激活==这个程序对象:
1
glUseProgram(shaderProgram);

在glUseProgram函数调用之后,每个着色器调用和渲染调用都会使用这个程序对象(也就是之前写的着色器)了。

在把着色器对象链接到程序对象以后,记得==删除着色器对象==,我们不再需要它们了:

1
2
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

小结

  • 我们已经把输入顶点数据发送给了GPU,并指示了GPU如何在顶点和片段着色器中处理它。

问题

  • OpenGL还不知道它该如何解释内存中的顶点数据,以及它该如何将顶点数据链接到顶点着色器的属性上

6)链接顶点属性

顶点着色器允许我们指定任何以顶点属性为形式的输入。这具有很强的灵活性,我们必须手动指定输入数据的哪一个部分对应顶点着色器的哪一个顶点属性。所以,我们必须在渲染前指定OpenGL该如何解释顶点数据。

我们的顶点缓冲数据会被解析为下面这样子(单位:字节):

image
  • 位置数据被储存为32位(4字节)浮点值。
  • 每个位置包含3个这样的值。
  • 在这3个值之间没有空隙(或其他值)。这几个值在数组中紧密排列(Tightly Packed)。
  • 数据中第一个值在缓冲开始的位置。

有了这些信息我们就可以使用glVertexAttribPointer函数告诉OpenGL该==如何解析顶点数据==(应用到逐个顶点属性上)了:

1
2
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

==glVertexAttribPointer==函数:

  • 功能:告诉OpenGL该==如何解析顶点数据==(应用到逐个顶点属性上)
  • 第一个参数指定我们要配置的顶点属性
    • 我们在顶点着色器中使用layout(location = 0)定义了position顶点属性的位置值(Location)。我们希望把数据传递到这一个顶点属性中,所以这里我们传入0
  • 第二个参数指定顶点属性的大小。顶点属性是一个vec3,它由3个值组成,所以大小是3。
  • 第三个参数指定数据的类型,这里是GL_FLOAT(GLSL中vec*都是由浮点数值组成的)。
  • 第四个参数定义我们是否希望数据被标准化(Normalize)。如果我们设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSE。
  • 第五个参数叫做步长(Stride),它告诉我们在连续的顶点属性组之间的间隔。
    • 由于下个组位置数据在3个float之后,我们把步长设置为3 * sizeof(float)。(这个参数的意思简单说就是从这个属性第二次出现的地方到整个数组0位置之间有多少字节)
    • 我们也可以设置为0来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。
  • 最后一个(第六个)参数的类型是void*,所以需要我们进行这个奇怪的强制类型转换。
    • 它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是0。

每个顶点属性从一个VBO管理的内存中获得它的数据,而具体是从哪个VBO(程序中可以有多个VBO)获取则是通过在调用glVertexAttribPointer时绑定到GL_ARRAY_BUFFER的VBO决定的。由于在调用glVertexAttribPointer之前绑定的是先前定义的VBO对象,顶点属性0现在会链接到它的顶点数据。

glEnableVertexAttribArray函数:

  • 功能:定义了OpenGL该如何解释顶点数据后,使用该函数以顶点属性位置值作为参数,==启用顶点属性==(顶点属性默认是禁用的)

渲染流程小结

着色器编写流程图

image

渲染流程简图

image

至此,所有东西都已经设置好了:我们使用一个顶点缓冲对象将顶点数据初始化至缓冲中,建立了一个顶点和一个片段着色器,并告诉了OpenGL如何把顶点数据链接到顶点着色器的顶点属性上。在OpenGL中绘制一个物体,代码会像是这样:

1
2
3
4
5
6
7
8
9
10
// 0. 复制顶点数组到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 1. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 2. 当我们渲染一个物体时要使用着色器程序
glUseProgram(shaderProgram);
// 3. 绘制物体
someOpenGLFunctionThatDrawsOurTriangle();

问题:每当我们绘制一个物体的时候都必须重复这一过程。一旦物体的顶点属性或数量变多,绑定正确的缓冲对象,为每个物体配置所有顶点属性很快就变成一件麻烦事

解决:把所有这些状态配置储存在一个对象中,并且可以通过绑定这个对象来恢复状态

顶点数组对象 VAO

==作用==:将VBO的工作流程打包,主要注重如何解释数据(如果VBO不变,那么数据还能复用)

顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。

这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。

OpenGL的核心模式要求我们使用VAO,所以它知道该如何处理我们的顶点输入。如果我们绑定VAO失败,OpenGL会拒绝绘制任何东西。

一个顶点数组对象会储存以下这些内容:

  • glEnableVertexAttribArray和glDisableVertexAttribArray的调用。
  • 通过glVertexAttribPointer设置的顶点属性配置。
  • 通过glVertexAttribPointer调用与顶点属性关联的顶点缓冲对象。
image

创建一个VAO和创建一个VBO很类似:

1
2
unsigned int VAO;
glGenVertexArrays(1, &VAO);

要想使用VAO,要做的只是使用glBindVertexArray绑定VAO。

从绑定之后起,我们应该绑定和配置对应的VBO和属性指针,之后解绑VAO供之后使用。

当我们打算绘制一个物体的时候,我们只要在绘制物体前简单地把VAO绑定到希望使用的设定上就行了。这段代码应该看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 1. 绑定VAO
glBindVertexArray(VAO);
// 2. 把顶点数组复制到缓冲中供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 设置顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: 绘制代码(渲染循环中) :: ..
// 4. 绘制物体
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
someOpenGLFunctionThatDrawsOurTriangle();

一般当你打算绘制多个物体时,你首先要生成/配置所有的VAO(和必须的VBO及属性指针),然后储存它们供后面使用。当我们打算绘制物体的时候就拿出相应的VAO,绑定它,绘制完物体后,再解绑VAO。


VAO小结

  • 引入VAO后关于VBO的代码只需要写一遍,避免每次绘制时重复写

引入VAO前(忽略初始化):

1
2
3
4
5
6
7
8
9
10
11
12
//绑定VBO
glBindBuffer(GL_ARRAY_BUFFER, VBO);
//传递数据给GPU
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
//设置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

glUseProgram(shaderProgram); //使用着色器程序
someOpenGLFunctionThatDrawsOurTriangle(); //绘制

glBindBuffer(GL_ARRAY_BUFFER, 0); //解绑VBO

引入VAO后(忽略初始化):

1
2
3
4
5
//绑定VAO
glBindVertexArray(VAO);
glUseProgram(shaderProgram); //使用着色器程序
someOpenGLFunctionThatDrawsOurTriangle(); //绘制
glBindVertexArray(0); //解绑VAO

元素缓冲对象 EBO

==作用==:利用顶点的索引定义形状,而避免重复定义顶点

元素缓冲对象(Element Buffer Object,EBO),也叫索引缓冲对象(Index Buffer Object,IBO)

要解释元素缓冲对象的工作方式==最好还是举个例子==:假设我们不再绘制一个三角形而是绘制一个矩形。我们可以绘制两个三角形来组成一个矩形(OpenGL主要处理三角形)。这会生成下面的顶点的集合:

1
2
3
4
5
6
7
8
9
10
float vertices[] = {
// 第一个三角形
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, 0.5f, 0.0f, // 左上角
// 第二个三角形
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};

可以看到,有几个顶点叠加了。我们指定了右下角左上角两次!一个矩形只有4个而不是6个顶点,这样就产生50%的额外开销。

更好的解决方案是只储存不同的顶点,并设定绘制这些顶点的顺序。这样子我们只要储存4个顶点就能绘制矩形了,之后只要指定绘制的顺序就行了。

元素缓冲区对象的工作方式正是如此。 EBO是一个缓冲区,就像一个顶点缓冲区对象一样,它存储 OpenGL 用来决定要绘制哪些顶点的索引。这种所谓的索引绘制(Indexed Drawing)正是我们问题的解决方案。

首先,我们先要定义(不重复的)顶点,和绘制出矩形所需的索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float vertices[] = {
0.5f, 0.5f, 0.0f, // 右上角
0.5f, -0.5f, 0.0f, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};

unsigned int indices[] = {
// 注意索引从0开始!
// 此例的索引(0,1,2,3)就是顶点数组vertices的下标,
// 这样可以由下标代表顶点组合成矩形

0, 1, 3, // 第一个三角形
1, 2, 3 // 第二个三角形
};

你可以看到,当使用索引的时候,我们只定义了4个顶点,而不是6个。下一步我们需要创建元素缓冲对象:

1
2
3
4
5
unsigned int EBO;
glGenBuffers(1, &EBO);

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO); //缓冲的类型定义为GL_ELEMENT_ARRAY_BUFFER
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

注意:我们传递了GL_ELEMENT_ARRAY_BUFFER当作缓冲目标。

glBufferData详见:[顶点缓冲对象 VBO](#顶点缓冲对象 VBO)


最后一件要做的事是用glDrawElements来替换glDrawArrays函数(本节源码用该函数绘制物体),表示我们要从索引缓冲区渲染三角形。使用glDrawElements时,我们会使用当前绑定的索引缓冲对象中的索引进行绘制:

1
2
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

==glDrawElements==函数:

  • 第一个参数指定了我们绘制的模式,这个和glDrawArrays的一样
  • 第二个参数是我们打算绘制顶点的个数,这里填6,也就是说我们一共需要绘制6个顶点
  • 第三个参数是索引的类型,这里是GL_UNSIGNED_INT
  • 第四个参数里我们可以指定EBO中的偏移量(或者传递一个索引数组,但是这是当你不在使用索引缓冲对象的时候),我们会在这里填写0。

glDrawElements函数从当前绑定到GL_ELEMENT_ARRAY_BUFFER目标的EBO中获取其索引。

这意味着我们每次想要使用索引渲染对象时都必须绑定相应的EBO,这又有点麻烦。

碰巧顶点数组对象(VAO)也跟踪元素缓冲区对象绑定。在绑定VAO时,绑定的==最后一个元素缓冲区对象==存储为VAO的==元素缓冲区对象(EBO)==。然后,绑定到VAO也会自动绑定该EBO。

image

当目标是GL_ELEMENT_ARRAY_BUFFER的时候,VAO会储存glBindBuffer的函数调用。这也意味着它也会储存解绑调用,所以==确保你没有在解绑VAO之前解绑索引数组缓冲==,否则它就没有这个EBO配置了。

  • 解绑VAO前不要解绑EBO
  • 解绑VAO前可以解绑VBO
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ..:: 初始化代码 :: ..
// 1. 绑定顶点数组对象
glBindVertexArray(VAO);
// 2. 把我们的顶点数组复制到一个顶点缓冲中,供OpenGL使用
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 3. 复制我们的索引数组到一个索引缓冲中,供OpenGL使用
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
// 4. 设定顶点属性指针
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

[...]

// ..:: 绘制代码(渲染循环中) :: ..
glUseProgram(shaderProgram);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
glBindVertexArray(0);

image

1.5 着色器

https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/

GLSL

着色器是使用一种叫GLSL的类C语言写成的。

GLSL是为图形计算量身定制的,它包含一些针对向量和矩阵操作的有用特性。

着色器的开头总是要声明版本,接着是输入和输出变量、uniform和main函数。

  • 每个着色器的入口点都是main函数,在这个函数中我们处理所有的输入变量,并将结果输出到输出变量中。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#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)。

我们能声明的顶点属性是有上限的,它一般由硬件来决定。

OpenGL确保==至少有16个包含4分量==的顶点属性可用,但是有些硬件或许允许更多的顶点属性,你可以查询GL_MAX_VERTEX_ATTRIBS来获取具体的上限:

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

通常情况下它至少会返回16个,大部分情况下是够用了。

数据类型

GLSL中包含C等其它语言大部分的默认基础数据类型:intfloatdoubleuintbool

GLSL也有两种容器类型,它们会在这个教程中使用很多,分别是==向量==(Vector)和==矩阵==(Matrix)

向量

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

类型 含义
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)。重组允许这样的语法:

1
2
3
4
vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

你可以使用上面4个字母任意组合来创建一个和原来向量一样长的(同类型)新向量,只要原来向量有那些分量即可


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

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

向量是一种灵活的数据类型,我们可以把它用在各种输入和输出上。

输入与输出

虽然着色器是各自独立的小程序,但是它们都是一个整体的一部分,出于这样的原因,我们希望每个着色器都有输入和输出,这样才能进行数据交流和传递。GLSL定义了inout关键字专门来实现这个目的。

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

顶点着色器:它从顶点数据中直接接收输入

  • 为了定义顶点数据该如何管理,我们使用location这一元数据指定输入变量,这样我们才可以在CPU上配置顶点属性。

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

片段着色器:它需要一个vec4颜色输出变量,因为片段着色器需要生成一个最终输出的颜色

所以,如果我们打算从一个着色器向另一个着色器发送数据,我们必须在发送方着色器中声明一个输出,在接收方着色器中声明一个类似的输入。当类型和名字都一样的时候,OpenGL就会把两个变量==链接==到一起,它们之间就能发送数据了(这是在链接程序对象时完成的)。

顶点着色器

1
2
3
4
5
6
7
8
9
10
#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); // 把输出变量设置为暗红色
}

片段着色器

1
2
3
4
5
6
7
8
9
#version 330 core
out vec4 FragColor;

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

void main()
{
FragColor = vertexColor;
}

我们在顶点着色器中声明了一个vertexColor变量作为vec4输出,并在片段着色器中声明了一个类似的vertexColor。由于它们==名字相同且类型相同==,片段着色器中的vertexColor就和顶点着色器中的vertexColor==链接==了。

完成了!我们成功地从顶点着色器向片段着色器发送数据。让我们更上一层楼,看看能否==从应用程序中==直接给==片段着色器==发送一个颜色!

image

Uniform

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

  • 首先,uniform是全局的(Global)。全局意味着uniform变量必须在每个着色器程序对象中都是独一无二的,而且它可以被着色器程序的任意着色器在任意阶段访问。
  • 第二,无论你把uniform值设置成什么,uniform会一直保存它们的数据,直到它们被重置或更新。

我们可以在一个着色器中添加uniform关键字至类型和变量名前来声明一个GLSL的uniform:

1
2
3
4
5
6
7
8
9
#version 330 core
out vec4 FragColor;

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

void main()
{
FragColor = ourColor;
}

因为uniform是全局变量,我们可以在任何着色器中定义它们,而无需通过顶点着色器作为中介。

image

这个uniform现在还是空的;我们还没有给它添加任何数据,所以下面我们就做这件事。

我们首先需要找到着色器中uniform属性的索引/位置值。当我们得到uniform的索引/位置值后,我们就可以更新它的值了。

这次我们不去给像素传递单独一个颜色,而是让它随着时间改变颜色:

1
2
3
4
5
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的。
image

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

但假如我们打算为==每个顶点设置一个颜色==的时候该怎么办?

这种情况下,我们就不得不声明和顶点数目一样多的uniform了。

这一问题上更好的解决方案是在顶点属性中包含更多的数据,这是我们接下来要做的事情。

更多属性!

我们将把颜色数据添加为3个float值至vertices数组。我们将把三角形的三个角分别指定为红色、绿色和蓝色:

1
2
3
4
5
6
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:

1
2
3
4
5
6
7
8
9
10
11
#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设置为我们从顶点数据那里得到的输入颜色
}

由于我们不再使用uniform来传递片段的颜色了,现在使用ourColor输出变量,我们必须再修改一下片段着色器:

1
2
3
4
5
6
7
8
#version 330 core
out vec4 FragColor;
in vec3 ourColor;

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

因为我们添加了另一个顶点属性,并且更新了VBO的内存,我们就必须重新配置顶点属性指针。更新后的VBO内存中的数据现在看起来像这样:

image

知道了现在使用的布局,我们就可以使用glVertexAttribPointer函数更新顶点格式,

1
2
3
4
5
6
// 位置属性
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);

glVertexAttribPointer详见:链接顶点属性

image

思考:这个图片可能不是你所期望的那种,因为我们只提供了3个颜色,而不是我们现在看到的大调色板。

答:这是在片段着色器中进行的所谓==片段插值==(Fragment Interpolation)的结果。当渲染一个三角形时,光栅化(Rasterization)阶段通常会造成比原指定顶点==更多的片段==。光栅会根据每个片段在三角形形状上所处相对位置决定这些片段的位置。基于这些位置,它会==插值==(Interpolate)所有片段着色器的输入变量。

  • 比如说,我们有一个线段,上面的端点是绿色的,下面的端点是蓝色的。如果一个片段着色器在线段的70%的位置运行,它的颜色输入属性就会是一个绿色和蓝色的线性结合;更精确地说就是30%蓝 + 70%绿。

我们自己的着色器类(略)

编写、编译、管理着色器是件麻烦事。在着色器主题的最后,我们会写一个类来让我们的生活轻松一点,它可以从硬盘读取着色器,然后编译并链接它们,并对它们进行错误检测,这就变得很好用了。这也会让你了解该如何封装目前所学的知识到一个抽象对象中。

该小节没有引入新概念,具体内容详见文档:https://learnopengl-cn.github.io/01%20Getting%20started/05%20Shaders/#_6

1.6 纹理

引入:我们已经了解到,我们可以为每个顶点添加颜色来增加图形的细节,从而创建出有趣的图像。但是,如果想让图形看起来更真实,我们就必须有足够多的顶点,从而指定足够多的颜色。这将会产生很多额外开销,因为每个模型都会需求更多的顶点,每个顶点又需求一个颜色属性。

==纹理==(Texture):纹理是一个2D图片(甚至也有1D和3D的纹理),它可以用来添加物体的细节;

image

为了能够把纹理==映射==(Map)到三角形上,我们需要指定三角形的每个顶点各自对应纹理的哪个部分。

这样每个顶点就会关联着一个==纹理坐标==(Texture Coordinate),用来标明该从纹理图像的哪个部分采样(译注:采集片段颜色)。之后在图形的其它片段上进行片段插值(Fragment Interpolation)。

  • 纹理坐标在x和y轴上,==范围为0到1之间==(注意我们使用的是2D纹理图像)。
  • 使用纹理坐标获取纹理颜色叫做==采样==(Sampling)。
  • 纹理坐标起始于(0, 0),也就是纹理图片的左下角,终于(1, 1),即纹理图片的右上角。(对于OpenGL)
image

我们只要给顶点着色器传递这三个纹理坐标就行了,接下来它们会被传入片段着色器中,它会为每个片段进行纹理坐标的插值。

纹理坐标看起来就像这样:

1
2
3
4
5
float texCoords[] = {
0.0f, 0.0f, // 左下角
1.0f, 0.0f, // 右下角
0.5f, 1.0f // 上中
};

对纹理采样的解释非常宽松,它可以采用几种不同的插值方式。所以我们需要自己告诉OpenGL该怎样对纹理采样

纹理环绕方式 Wrap

==作用==:当纹理坐标超出[0, 1]范围时,该如何采样

思考:纹理坐标的范围通常是从(0, 0)到(1, 1),那如果我们把纹理坐标设置在范围之外会发生什么?

环绕方式 描述
GL_REPEAT 对纹理的==默认==行为。重复纹理图像。
GL_MIRRORED_REPEAT 和GL_REPEAT一样,但每次重复图片是镜像放置的。
GL_CLAMP_TO_EDGE 纹理坐标会被约束在0到1之间,超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。
GL_CLAMP_TO_BORDER 超出的坐标为用户指定的边缘颜色。
image

前面提到的每个选项都可以使用glTexParameteri函数对单独的一个坐标轴设置(st(如果是使用3D纹理那么还有一个r)它们和xyz是等价的):

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);

==glTexParameteri==:

  • 第一个参数指定了纹理目标;

    • 我们使用的是2D纹理,因此纹理目标是GL_TEXTURE_2D。
  • 第二个参数需要我们指定设置的选项与应用的纹理轴。我们打算配置的是WRAP选项,并且指定ST轴。

  • 最后一个参数需要我们传递一个环绕方式(Wrapping),在这个例子中OpenGL会给当前激活的纹理设定纹理环绕方式为GL_MIRRORED_REPEAT

    • 如果我们选择GL_CLAMP_TO_BORDER选项,我们还需要指定一个边缘的颜色。

    • 这需要使用glTexParameter函数的fv后缀形式,用GL_TEXTURE_BORDER_COLOR作为它的选项,并且传递一个float数组作为边缘的颜色值:

    • float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
      glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45

      ### 纹理过滤 Filter

      ==作用==:当物体很大,纹理分辨率很小时,该如何采样

      纹理坐标不依赖于分辨率(Resolution),它可以是任意浮点值,所以OpenGL需要知道怎样将纹理像素(Texture Pixel,也叫Texel,译注1)映射到纹理坐标。

      当你有一个==很大的物体==但是==纹理的分辨率很低==的时候这就变得很重要了。你可能已经猜到了,OpenGL也有对于纹理过滤(Texture Filtering)的选项。

      <img src="Learn OpenGL.assets/image-20230327201325834.png" alt="image-20230327201325834" style="zoom:67%;" />

      纹理过滤有很多个选项,但是现在我们只讨论最重要的两种:`GL_NEAREST`和`GL_LINEAR`。

      <hr>


      GL_NEAREST(也叫邻近过滤,Nearest Neighbor Filtering)是OpenGL默认的纹理过滤方式。

      当设置为GL_NEAREST的时候,OpenGL会选择中心点最接近纹理坐标的那个像素。下图中你可以看到四个像素,加号代表纹理坐标。左上角那个纹理像素的中心距离纹理坐标最近,所以它会被选择为样本颜色:

      <img src="Learn OpenGL.assets/image-20230327201447341.png" alt="image-20230327201447341" style="zoom:67%;" />

      <hr>


      GL_LINEAR(也叫线性过滤,(Bi)linear Filtering)它会基于纹理坐标附近的纹理像素,计算出一个插值,近似出这些纹理像素之间的颜色。

      一个纹理像素的中心距离纹理坐标越近,那么这个纹理像素的颜色对最终的样本颜色的贡献越大。下图中你可以看到返回的颜色是邻近像素的混合色:

      <img src="Learn OpenGL.assets/image-20230327201523580.png" alt="image-20230327201523580" style="zoom:67%;" />

      那么这两种纹理过滤方式有怎样的视觉效果呢?让我们看看在一个很大的物体上应用一张低分辨率的纹理会发生什么吧(纹理被放大了,每个纹理像素都能看到):

      <img src="Learn OpenGL.assets/image-20230327201538748.png" alt="image-20230327201538748" style="zoom:67%;" />

      <hr>


      当进行==放大==(Magnify)和==缩小==(Minify)操作的时候可以设置纹理过滤的选项,比如你可以在**纹理被缩小**的时候使用**邻近过滤**,**被放大**时使用**线性过滤**。

      我们需要使用glTexParameteri函数为放大和缩小指定过滤方式。这段代码看起来会和纹理环绕方式的设置很相似:

      ```c++
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); //纹理缩小通常使用下小节提到的Mipmap
      glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //纹理放大通常用线性

多级渐远纹理 Mipmap

==作用==:当物体很小,纹理分辨率很大时,该如何采样

引入:有些物体会很远,但其纹理会拥有与近处物体同样高的分辨率。由于远处的物体可能只产生很少的片段,OpenGL从高分辨率纹理中为这些片段获取正确的颜色值就很困难,因为它需要对一个跨过纹理很大部分的片段只拾取一个纹理颜色。在小物体上这会产生不真实的感觉,更不用说对它们使用高分辨率纹理浪费内存的问题了。

多级渐远纹理(Mipmap):

  • 它简单来说就是一系列的纹理图像,后一个纹理图像是前一个的二分之一。
  • 多级渐远纹理背后的理念很简单:距观察者的距离超过一定的阈值,OpenGL会使用不同的多级渐远纹理,即最适合物体的距离的那个。由于距离远,解析度不高也不会被用户注意到。
  • 同时,多级渐远纹理另一加分之处是它的性能非常好。
image

手工为每个纹理图像创建一系列多级渐远纹理很麻烦,幸好OpenGL有一个==glGenerateMipmaps==函数,在创建完一个纹理后调用它OpenGL就会承担接下来的所有工作了。


思考:在渲染中==切换==多级渐远纹理级别(Level)时,OpenGL在两个不同级别的多级渐远纹理层之间会产生==不真实的生硬边界==。

解决:就像普通的纹理过滤一样,切换多级渐远纹理级别时你也可以在两个不同多级渐远纹理级别之间使用NEAREST和LINEAR过滤。为了指定不同多级渐远纹理级别之间的过滤方式,你可以使用下面四个选项中的一个代替原有的过滤方式:

过滤方式 描述
GL_NEAREST_MIPMAP_NEAREST 使用最邻近的多级渐远纹理来匹配像素大小,并使用邻近插值进行纹理采样
GL_LINEAR_MIPMAP_NEAREST 使用最邻近的多级渐远纹理级别,并使用线性插值进行采样
GL_NEAREST_MIPMAP_LINEAR 在两个最匹配像素大小的多级渐远纹理之间进行线性插值,使用邻近插值进行采样
GL_LINEAR_MIPMAP_LINEAR 在两个邻近的多级渐远纹理之间使用线性插值,并使用线性插值进行采样

就像纹理过滤一样,我们可以使用glTexParameteri将过滤方式设置为前面四种提到的方法之一:

1
2
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); //纹理缩小用Mipmap
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); //纹理放大通常用线性

glTexParameteri详见:[纹理环绕方式 Wrap](#纹理环绕方式 Wrap)

一个==常见的错误==是,将放大过滤的选项设置为多级渐远纹理过滤选项之一。这样没有任何效果,因为多级渐远纹理主要是使用在纹理被缩小的情况下的:纹理放大不会使用多级渐远纹理,为放大过滤设置多级渐远纹理的选项会产生一个GL_INVALID_ENUM错误代码。

加载与创建纹理

使用纹理之前要做的第一件事是把它们==加载==到我们的应用中。

  • 一个解决方案是选一个需要的文件格式,比如.PNG,然后自己写一个图像加载器,把图像转化为字节序列。写自己的图像加载器虽然不难,但仍然挺麻烦的,而且如果要支持更多文件格式呢?你就不得不为每种你希望支持的格式写加载器了。
  • 另一个解决方案也许是一种更好的选择,使用一个支持多种流行格式的图像加载库来为我们解决这个问题。比如说我们要用的stb_image.h库。

stb_image.h

stb_image.hSean Barrett的一个非常流行的==单头文件图像加载库==,它能够加载大部分流行的文件格式,并且能够很简单得整合到你的工程之中。

1
2
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"

通过定义STB_IMAGE_IMPLEMENTATION,预处理器会修改头文件,让其只包含相关的函数定义源码,等于是将这个头文件变为一个 .cpp 文件了。现在只需要在你的程序中包含stb_image.h并编译就可以了。


要使用stb_image.h加载图片,我们需要使用它的stbi_load函数:

1
2
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);

stbi_load:

  • 首先接受一个图像文件的位置作为输入。
  • 接下来它需要三个int*作为它的第二、第三和第四个参数,stb_image.h将会用图像的宽度高度颜色通道的个数填充这三个变量。

生成纹理

和之前生成的OpenGL对象一样,纹理也是使用ID引用的。让我们来创建一个:

1
2
unsigned int texture;
glGenTextures(1, &texture);

就像其他对象一样,我们需要绑定它,让之后任何的纹理指令都可以配置当前绑定的纹理:

1
glBindTexture(GL_TEXTURE_2D, texture);

现在纹理已经绑定了,我们可以使用前面载入的图片数据生成一个纹理了。纹理可以通过glTexImage2D来生成:

1
2
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

==glTexImage2D==:

  • 第一个参数指定了==纹理目标==(Target)。
    • 设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
  • 第二个参数为纹理指定==多级渐远纹理的级别==,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
  • 第三个参数告诉OpenGL我们希望把纹理==储存为何种格式==。我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
  • 第四个和第五个参数设置==最终的纹理的宽度和高度==。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
  • 下个参数应该总是被设为0(历史遗留的问题)。
  • 第七第八个参数定义了==源图的格式和数据类型==。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
  • 最后一个参数是真正的图像数据。

当调用glTexImage2D时,当前绑定的纹理对象就会被==附加==上纹理图像。

  • 然而,目前只有基本级别(Base-level)的纹理图像被加载了,如果要==使用多级渐远纹理==,我们必须手动设置所有不同的图像(不断递增第二个参数)。或者,直接在生成纹理之后调用glGenerateMipmap。这会为当前绑定的纹理自动生成所有需要的多级渐远纹理。

生成了纹理和相应的多级渐远纹理后,释放图像的内存是一个很好的习惯。

1
stbi_image_free(data);

生成一个纹理的过程应该看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

// 为当前绑定的纹理对象设置环绕、过滤方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); //这行没问题吗?这样不就没用Mipmap了?
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

// 加载并生成纹理
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
}
else
{
std::cout << "Failed to load texture" << std::endl;
}
stbi_image_free(data);

应用纹理

后面的这部分我们会使用glDrawElements绘制[「你好,三角形」](https://learnopengl-cn.github.io/01 Getting started/04 Hello Triangle/)教程最后一部分的矩形。我们需要告知OpenGL如何采样纹理,所以我们必须使用纹理坐标更新顶点数据:

1
2
3
4
5
6
7
float vertices[] = {
// ---- 位置 ---- ---- 颜色 ---- - 纹理坐标 -
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, // 右下
-0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, // 左下
-0.5f, 0.5f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f // 左上
};
image

额外设置纹理的顶点坐标属性

1
2
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
glEnableVertexAttribArray(2);

注意,我们同样需要调整前面两个顶点属性的步长参数为8 * sizeof(float)


接着我们需要调整顶点着色器使其能够接受顶点坐标为一个顶点属性,并把坐标传给片段着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aTexCoord;

out vec3 ourColor;
out vec2 TexCoord;

void main()
{
gl_Position = vec4(aPos, 1.0);
ourColor = aColor;
TexCoord = aTexCoord;
}

片段着色器应该接下来会把输出变量TexCoord作为输入变量。


思考:片段着色器也应该能访问纹理对象,但是我们怎样能把纹理对象传给片段着色器呢?

解决:GLSL有一个供纹理对象使用的内建数据类型,叫做采样器(Sampler),它以纹理类型作为后缀,比如sampler1Dsampler3D,或在我们的例子中的sampler2D

  • 我们可以简单声明一个uniform sampler2D把一个纹理添加到片段着色器中,稍后我们会把纹理赋值给这个uniform。
1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
out vec4 FragColor;

in vec3 ourColor;
in vec2 TexCoord;

uniform sampler2D ourTexture;

void main()
{
FragColor = texture(ourTexture, TexCoord);
}

==texture==:

  • 功能:使用GLSL内建的texture函数来采样纹理的颜色
  • 第一个参数是纹理采样器
  • 第二个参数是对应的纹理坐标
  • 输出:经过插值得到的纹理坐标上对应的颜色

现在只剩下在调用glDrawElements之前绑定纹理了,它会==自动==把纹理赋值给片段着色器的采样器:

1
2
3
glBindTexture(GL_TEXTURE_2D, texture);
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);
image

纹理单元

思考:为什么sampler2D变量是个uniform,我们却不用glUniform给它赋值?

使用glUniform1i,我们可以给纹理采样器分配一个位置值,这样的话我们能够在一个片段着色器中设置多个纹理。

一个纹理的位置值通常称为一个纹理单元(Texture Unit)。一个纹理的默认纹理单元是0,它是默认的激活纹理单元,所以教程前面部分我们没有分配一个位置值。

==目的==:让我们在着色器中可以使用多于一个的纹理。

通过把纹理单元赋值给采样器,我们可以一次绑定多个纹理,只要我们首先激活对应的纹理单元。就像glBindTexture一样,我们可以使用glActiveTexture激活纹理单元,传入我们需要使用的纹理单元:

1
2
glActiveTexture(GL_TEXTURE0); // 在绑定纹理之前先激活纹理单元
glBindTexture(GL_TEXTURE_2D, texture);

激活纹理单元之后,接下来的glBindTexture函数调用会绑定这个纹理到当前激活的纹理单元,纹理单元==GL_TEXTURE0默认总是被激活==,所以我们在前面的例子里当我们使用glBindTexture的时候,无需激活任何纹理单元。

image

我们仍然需要编辑片段着色器来接收另一个采样器。这应该相对来说非常直接了:

1
2
3
4
5
6
7
8
9
10
#version 330 core
...

uniform sampler2D texture1;
uniform sampler2D texture2;

void main()
{
FragColor = mix(texture(texture1, TexCoord), texture(texture2, TexCoord), 0.2);
}

最终输出颜色现在是两个纹理的结合。

GLSL内建的mix函数需要接受两个值作为参数,并对它们根据第三个参数进行线性插值。


为了使用第二个纹理(以及第一个),我们必须改变一点渲染流程,先绑定两个纹理到对应的纹理单元,然后定义哪个uniform采样器对应哪个纹理单元:

1
2
3
4
5
6
7
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

我们还要通过使用glUniform1i设置每个采样器的方式告诉OpenGL每个着色器采样器属于哪个纹理单元。我们只需要设置一次即可,所以这个会放在渲染循环的前面:

1
2
3
4
5
6
7
8
ourShader.use(); // 不要忘记在设置uniform变量之前激活着色器程序!
glUniform1i(glGetUniformLocation(ourShader.ID, "texture1"), 0); // 手动设置
ourShader.setInt("texture2", 1); // 或者使用着色器类设置

while(...)
{
[...]
}

image

你可能注意到纹理上下颠倒了!这是因为OpenGL要求y轴0.0坐标是在图片的底部的,但是图片的y轴0.0坐标通常在顶部。

很幸运,stb_image.h能够在图像加载时帮助我们翻转y轴,只需要在加载任何图像前加入以下语句即可:

1
stbi_set_flip_vertically_on_load(true);
image

疑问(待解决 2023年3月28日21:12:37)

问:生成纹理数据后该怎么手动释放???

答:ChatGPT回答glDeleteTextures(1, &textureID);可以释放

1.7 变换

https://learnopengl-cn.github.io/01%20Getting%20started/07%20Transformations/

本节偏数学且较为基础,大部分内容略

角度 <-> 弧度

image

四元数

对四元数的理解会用到非常多的数学知识。如果你想了解四元数与3D旋转之间的关系,可以来阅读我的教程。如果你对万向节死锁的概念仍不是那么清楚,可以来阅读我教程的Bonus章节

现在3Blue1Brown也已经开始了一个四元数的视频系列,他采用球极平面投影(Stereographic Projection)的方式将四元数投影到3D空间,同样有助于理解四元数的概念(仍在更新中):https://www.youtube.com/watch?v=d4EgbgTm0Bg


四元数乘法

image image

四元数的模

image image

四元数的性质

image

对j,k的变换采用右手定则

  • 例如:ij,表示j点沿i轴旋转(右手定则大拇指对准i,握拳为旋转方向)
image

四元数控制旋转(记忆)

不用管推导,只需要会类比记忆即可

关于复平面,乘以cos(θ)+sin(θ)*i 可以表示一个旋转

image

类比二维空间,对于三维空间

image

展开下面的式子(qpq^-1^)就能得到旋转后的结果

image

结论:

  • q是单位四元数构成的向量。w0是cos、旋转角得到的,x0是sin、旋转角和旋转轴x轴分量得到的(y0,z0依次类推)
  • 由于q中i、j、k是固定的三个轴,不会变化,所以三维软件里控制的四元数就是控制(w0,x0,y0,z0
  • 旋转轴(xi,yi,zi)是单位向量(xyz和x0y0z0虽然都是标量但并不相同,后者是经过sin和旋转角的运算得到的标量)
  • q的逆是w0不变,其他取反
  • qpq^-1^最终计算的结果就是p绕特定轴旋转特定角度而得到的点

举例:打开Blender,用四元数和欧拉角的方式去实现绕x轴转45且绕y轴转45度的效果。

GLM 数学库

OpenGL没有自带任何的矩阵和向量知识,所以我们必须定义自己的数学类和函数。

GLM是专门为OpenGL量身定做的数学库

GLM是OpenGL Mathematics的缩写,它是一个只有头文件的库,也就是说我们只需包含对应的头文件就行了,不用链接和编译。

image

本人使用和文档一致的老版本:0.9.8.5


我们需要的GLM的大多数功能都可以从下面这3个头文件中找到:

1
2
3
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>

把一个向量(1, 0, 0)位移(1, 1, 0)个单位(注意,我们把它定义为一个glm::vec4类型的值,齐次坐标设定为1.0):

1
2
3
4
5
6
7
8
9
glm::vec4 vec(1.0f, 0.0f, 0.0f, 1.0f);
// 译注:下面就是矩阵初始化的一个例子,如果使用的是0.9.9及以上版本
// 下面这行代码就需要改为:
// glm::mat4 trans = glm::mat4(1.0f)
// 之后将不再进行提示
glm::mat4 trans;
trans = glm::translate(trans, glm::vec3(1.0f, 1.0f, 0.0f));
vec = trans * vec;
std::cout << vec.x << vec.y << vec.z << std::endl; //这个代码片段将会输出210

我们把之前教程中的那个箱子逆时针旋转90度。然后缩放0.5倍,使它变成原来的一半大。我们先来创建变换矩阵:

1
2
3
glm::mat4 trans;
trans = glm::rotate(trans, glm::radians(90.0f), glm::vec3(0.0, 0.0, 1.0));
trans = glm::scale(trans, glm::vec3(0.5, 0.5, 0.5));

GLM希望它的角度是弧度制的(Radian),使用glm::radians将角度转化为弧度。

注意有纹理的那面矩形是在XY平面上的,所以需要把它绕着z轴旋转。

因为我们把这个矩阵传递给了GLM的每个函数,GLM会==自动将矩阵相乘==,返回的结果是一个包括了多个变换的变换矩阵。

接着,修改顶点着色器并传入矩阵

  • GLSL里也有一个mat4类型
  • 所以我们将修改顶点着色器让其接收一个mat4的uniform变量,然后再用矩阵uniform乘以位置向量:
1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;

out vec2 TexCoord;

uniform mat4 transform;

void main()
{
gl_Position = transform * vec4(aPos, 1.0f);
TexCoord = vec2(aTexCoord.x, 1.0 - aTexCoord.y);
}
image
1
2
3
ourShader.use();
unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));

==glUniformMatrix4fv==:

  • 第一个参数是uniform的位置值。
  • 第二个参数告诉OpenGL我们将要发送多少个矩阵,这里是1。
  • 第三个参数询问我们是否希望对我们的矩阵进行转置(Transpose),也就是说交换我们矩阵的行和列。
    • OpenGL开发者通常使用一种内部矩阵布局,叫做列主序(Column-major Ordering)布局。GLM的默认布局就是列主序,所以并不需要转置矩阵,我们填GL_FALSE
  • 最后一个参数是真正的矩阵数据。
    • 但是GLM并不是把它们的矩阵储存为OpenGL所希望接受的那种,因此我们要先用GLM的自带的函数value_ptr来变换这些数据。
image

1.8 坐标系统

https://learnopengl-cn.github.io/01%20Getting%20started/08%20Coordinate%20Systems/

将坐标变换为[标准化设备坐标 NDC](#标准化设备坐标 NDC),接着再转化为屏幕坐标的过程通常是分步进行的,也就是类似于流水线那样子。

在流水线中,物体的顶点在最终转化为屏幕坐标之前还会被变换到多个坐标系统(Coordinate System)。

物体的坐标变换到几个过渡坐标系(Intermediate Coordinate System)的优点在于,在这些特定的坐标系统中,一些操作或运算更加方便和容易

对我们来说比较重要的总共有5个不同的坐标系统:

  • 局部空间(Local Space,或者称为物体空间(Object Space))
  • 世界空间(World Space)
  • 观察空间(View Space,或者称为视觉空间(Eye Space))
  • 裁剪空间(Clip Space)
  • 屏幕空间(Screen Space)

概述

image
  1. 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。

  2. 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。

  3. 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。

  4. 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。

    • 由投影矩阵创建的观察箱(Viewing Box)被称为平截头体(Frustum),每个出现在平截头体范围内的坐标都会最终出现在用户的屏幕上。

    • image
    • 将特定范围内的坐标转化到标准化设备坐标系的过程(而且它很容易被映射到2D观察空间坐标)被称之为==投影==(Projection),使用投影矩阵能将3D坐标投影(Project)到2D的标准化设备坐标系中。

    • 一旦所有顶点被变换到裁剪空间,最终的操作——==透视除法==(Perspective Division)将会执行,在这个过程中我们将位置向量的x,y,z分量分别除以向量的齐次w分量;透视除法是将4D裁剪空间坐标变换为3D标准化设备坐标的过程。这一步会在每一个顶点着色器运行的最后被自动执行。

  5. 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。


自总结

  • 空间是名词,变换是动词
image

正射投影

正射投影矩阵定义了一个类似立方体的平截头箱,它定义了一个裁剪空间,在这空间之外的顶点都会被裁剪掉。

创建一个正射投影矩阵需要指定可见平截头体的宽、高和长度

正射平截头体直接将平截头体内部的所有坐标映射为标准化设备坐标,因为每个向量的w分量都没有进行改变;如果w分量等于1.0,透视除法则不会改变这个坐标。

image

要创建一个正射投影矩阵,我们可以使用GLM的内置函数glm::ortho

1
glm::ortho(0.0f, 800.0f, 0.0f, 600.0f, 0.1f, 100.0f);

==glm::ortho==函数:

  • 功能:将处于这些x,y,z值范围内的坐标变换为标准化设备坐标。
  • 前两个参数指定了平截头体的左右坐标
  • 第三和第四参数指定了平截头体的底部和顶部
  • 第五和第六个参数则定义了近平面和远平面的距离

透视投影

在GLM中可以这样创建一个透视投影矩阵:

1
glm::mat4 proj = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
image

==glm::perspective==函数:

  • 第一个参数定义了fov的值,它表示的是视野(Field of View),相当于设置了观察空间的大小。
  • 第二个参数设置了宽高比,由视口的宽除以高所得。
  • 第三和第四个参数设置了平截头体的平面。

右手坐标系(Right-handed System)

按照惯例,OpenGL是一个右手坐标系。简单来说,就是正x轴在你的右手边,正y轴朝上,而正z轴是朝向后方的。想象你的屏幕处于三个轴的中心,则正z轴穿过你的屏幕朝向你。坐标系画起来如下:

image

右手定则:

  • 沿着正y轴方向伸出你的右臂,手指着上方。
  • 大拇指指向右方。
  • 食指指向上方。
  • 中指向下弯曲90度。

如果你的动作正确,那么你的大拇指指向正x轴方向,食指指向正y轴方向,中指指向正z轴方向。如果你用左臂来做这些动作,你会发现z轴的方向是相反的。这个叫做左手坐标系,它被DirectX广泛地使用。注意在标准化设备坐标系OpenGL实际上使用的是左手坐标系(投影矩阵交换了左右手)。

Z缓冲

OpenGL存储它的所有深度信息于一个Z缓冲(Z-buffer)中,也被称为==深度缓冲==(Depth Buffer)。GLFW会自动为你生成这样一个缓冲(就像它也有一个颜色缓冲来存储输出图像的颜色)。

深度值存储在每个片段里面(作为片段的z值),当片段想要输出它的颜色时,OpenGL会将它的深度值和z缓冲进行比较,如果当前的片段在其它片段之后,它将会被丢弃,否则将会覆盖。这个过程称为==深度测试==(Depth Testing),它是由OpenGL自动完成的。

如果我们想要确定OpenGL真的执行了深度测试,首先我们要告诉OpenGL我们想要启用深度测试;它默认是关闭的。

1
glEnable(GL_DEPTH_TEST);

因为我们使用了深度测试,我们也想要在每次渲染迭代之前清除深度缓冲(否则前一帧的深度信息仍然保存在缓冲中)。就像清除颜色缓冲一样,我们可以通过在glClear函数中指定DEPTH_BUFFER_BIT位来清除深度缓冲:

1
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

1.9 摄像机

https://learnopengl-cn.github.io/01%20Getting%20started/09%20Camera/

本节我们将会讨论如何在OpenGL中配置一个摄像机,并且将会讨论FPS风格的摄像机,让你能够在3D场景中自由移动。我们也会讨论键盘和鼠标输入,最终完成一个自定义的摄像机类。

摄像机/观察空间

当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。

定义一个摄像机,我们需要它在==世界空间中的位置==、==观察的方向==、==一个指向它右侧的向量==以及==一个指向它上方的向量==。

我们实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。

image

1)摄像机位置

获取摄像机位置很简单。摄像机位置简单来说就是世界空间中一个指向摄像机位置的向量:

1
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);

2)摄像机方向

下一个需要的向量是摄像机的方向,这里指的是摄像机指向哪个方向。现在我们让摄像机指向场景原点:(0, 0, 0)。

用场景原点向量减去摄像机位置向量的结果就是摄像机的指向向量。由于我们知道摄像机指向z轴负方向,但我们希望方向向量(Direction Vector)指向摄像机的z轴正方向

如果我们交换相减的顺序,我们就会获得一个指向摄像机正z轴方向的向量:

1
2
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraDirection = glm::normalize(cameraPos - cameraTarget);
image

3)右轴

我们需要的另一个向量是一个右向量(Right Vector),它代表摄像机空间的x轴的正方向。

为获取右向量我们需要先使用一个小技巧:先定义一个上向量(Up Vector)。接下来把上向量和第二步得到的方向向量进行叉乘

两个向量叉乘的结果会同时垂直于两向量,因此我们会得到指向x轴正方向的那个向量(如果我们交换两个向量叉乘的顺序就会得到相反的指向x轴负方向的向量):

1
2
glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f); 
glm::vec3 cameraRight = glm::normalize(glm::cross(up, cameraDirection)); //OpenGL的叉乘符合右手螺旋定则

4)上轴

现在我们已经有了x轴向量和z轴向量,获取一个指向摄像机的正y轴向量就相对简单了:我们把右向量和方向向量进行叉乘:

1
glm::vec3 cameraUp = glm::cross(cameraDirection, cameraRight);

使用这些摄像机向量我们就可以创建一个LookAt矩阵了,它在创建摄像机的时候非常有用。

Look At

使用矩阵的好处之一是如果你使用3个相互垂直(或非线性)的轴定义了一个坐标空间,你可以用这3个轴外加一个平移向量来创建一个矩阵,并且你可以用这个矩阵乘以任何向量来将其变换到那个坐标空间。

这正是LookAt矩阵所做的,现在我们有了3个相互垂直的轴和一个定义摄像机空间的位置坐标,我们可以创建我们自己的LookAt矩阵了:

image

其中R是右向量,U是上向量,D是方向向量P是摄像机位置向量。

注意,位置向量是相反的,因为我们最终希望把世界平移到与我们自身移动的相反方向。

幸运的是,GLM已经提供了这些支持。我们要做的只是定义一个摄像机位置,一个目标位置和一个表示世界空间中的上向量的向量(我们计算右向量使用的那个上向量)。

接着GLM就会创建一个LookAt矩阵,我们可以把它当作我们的观察矩阵:

1
2
3
4
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f),
glm::vec3(0.0f, 0.0f, 0.0f),
glm::vec3(0.0f, 1.0f, 0.0f));

glm::LookAt函数需要一个位置、目标和上向量。

自由移动

首先我们必须设置一个摄像机系统,所以在我们的程序前面定义一些摄像机变量很有用:

1
2
3
glm::vec3 cameraPos   = glm::vec3(0.0f, 0.0f,  3.0f);
glm::vec3 cameraFront = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);

LookAt函数现在成了:

1
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);

我们首先将摄像机位置设置为之前定义的cameraPos。

方向是当前的位置加上我们刚刚定义的方向向量。这样能保证无论我们怎么移动,摄像机都会注视着目标方向。(摄像机正前方)

让我们摆弄一下这些向量,在按下某些按钮时更新cameraPos向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
void processInput(GLFWwindow *window)
{
...
float cameraSpeed = 0.05f; // adjust accordingly
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}

当我们按下WASD键的任意一个,摄像机的位置都会相应更新。

  • 如果我们希望向前或向后移动,我们就把位置向量加上或减去方向向量。
  • 如果我们希望向左右移动,我们使用叉乘来创建一个右向量(Right Vector),并沿着它相应移动就可以了。

移动速度

实际情况下根据处理器的能力不同,有些人可能会比其他人每秒绘制更多帧,也就是以更高的频率调用processInput函数。当你发布你的程序的时候,你必须确保它在所有硬件上移动速度都一样。

图形程序和游戏通常会跟踪一个时间差(Deltatime)变量,它储存了渲染上一帧所用的时间。我们把所有速度都去乘以deltaTime值。结果就是,如果我们的deltaTime很大,就意味着上一帧的渲染花费了更多时间,所以这一帧的速度需要变得更高来平衡渲染所花去的时间。

使用这种方法时,无论你的电脑快还是慢,摄像机的速度都会相应平衡,这样每个用户的体验就都一样了。

我们跟踪两个全局变量来计算出deltaTime值:

1
2
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间

在每一帧中我们计算出新的deltaTime以备后用。

1
2
3
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;

现在我们有了deltaTime,在计算速度的时候可以将其考虑进去了:

1
2
3
4
5
void processInput(GLFWwindow *window)
{
float cameraSpeed = 2.5f * deltaTime;
...
}

视角移动

只用键盘移动没什么意思。特别是我们还不能转向,移动很受限制。是时候加入鼠标了!

为了能够改变视角,我们需要根据鼠标的输入改变cameraFront向量。

欧拉角

欧拉角(Euler Angle)是可以表示3D空间中任何旋转的3个值,由莱昂哈德·欧拉(Leonhard Euler)在18世纪提出。

一共有3种欧拉角:俯仰角(Pitch)、偏航角(Yaw)和滚转角(Roll),下面的图片展示了它们的含义:

image

图1:==俯仰角==是描述我们如何往上或往下看的角

图2:==偏航角==表示我们往左和往右看的程度

图3:==滚转角==代表我们如何翻滚摄像机,通常在太空飞船的摄像机中使用。

每个欧拉角都有一个值来表示,把三个角结合起来我们就能够计算3D空间中任何的旋转向量了。

对于我们的摄像机系统来说,我们只关心俯仰角和偏航角,所以我们不会讨论滚转角。

给定一个俯仰角和偏航角,我们可以把它们转换为一个代表新的方向向量的3D向量。俯仰角和偏航角转换为方向向量的处理需要一些三角学知识,我们先从最基本的情况开始:

image

如果我们把斜边边长定义为1,我们就能知道邻边的长度是cos x/h=cos x/1=cos x,它的对边是sin y/h=sin y/1=sin y


计算Pitch

获得了能够得到x和y方向长度的通用公式,它们取决于所给的角度。我们使用它来计算方向向量的分量:

image

这个三角形看起来和前面的三角形很像,所以如果我们想象自己在xz平面上,看向y轴,我们可以基于第一个三角形计算来计算它的长度/y方向的强度(Strength)(我们往上或往下看多少)。

从图中我们可以看到对于一个给定俯仰角的y值等于sin θ

1
direction.y = sin(glm::radians(pitch)); // 注意我们先把角度转为弧度

这里我们只更新了y值,仔细观察x和z分量也被影响了。从三角形中我们可以看到它们的值等于:

1
2
direction.x = cos(glm::radians(pitch));
direction.z = cos(glm::radians(pitch));

计算Yaw

看看我们是否能够为偏航角找到需要的分量:

image

就像俯仰角的三角形一样,我们可以看到x分量取决于cos(yaw)的值,z值同样取决于偏航角的正弦值

把这个加到前面的值中,会得到基于俯仰角和偏航角的方向向量:

1
2
3
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw)); // 译注:direction代表摄像机的前轴(Front),这个前轴是和本文第一幅图片的第二个摄像机的方向向量是相反的
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));

这样我们就有了一个可以把俯仰角和偏航角转化为用来自由旋转视角的摄像机的3维方向向量了。

image

direction+camerapos才是目标点位置值,因此direction只是一个相对差值。在上面的手绘图中可以把原点想象成相机位置,目标点在半径为r的球上运动

鼠标输入

偏航角和俯仰角是通过鼠标(或手柄)移动获得的,水平的移动影响偏航角,竖直的移动影响俯仰角。

  • 它的原理就是,储存上一帧鼠标的位置,在当前帧中我们当前计算鼠标位置上一帧的位置相差多少
  • 如果水平/竖直差别越大那么俯仰角或偏航角就改变越大,也就是摄像机需要移动更多的距离。

首先我们要告诉GLFW,它应该隐藏光标,并==捕捉==(Capture)它。

  • 捕捉光标表示的是,如果焦点在你的程序上,光标应该停留在窗口中(除非程序失去焦点或者退出)。
  • (译注:即表示你正在操作这个程序,Windows中拥有焦点的程序标题栏通常是有颜色的那个,而失去焦点的程序标题栏则是灰色的)

我们可以用一个简单地配置调用来完成:

1
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);

在调用这个函数之后,无论我们怎么去移动鼠标,光标都不会显示了,它也不会离开窗口。对于FPS摄像机系统来说非常完美。


设置鼠标回调函数

为了计算俯仰角和偏航角,我们需要让GLFW监听鼠标移动事件。(和键盘输入相似)我们会用一个回调函数来完成,函数的原型如下:

1
void mouse_callback(GLFWwindow* window, double xpos, double ypos);

这里的xpos和ypos代表当前鼠标的位置。当我们用GLFW注册了回调函数之后,鼠标一移动mouse_callback函数就会被调用:

1
glfwSetCursorPosCallback(window, mouse_callback);

在处理FPS风格摄像机的鼠标输入的时候,我们必须在最终获取方向向量之前做下面这几步:

  1. 计算鼠标距上一帧的偏移量。
  2. 把偏移量添加到摄像机的俯仰角和偏航角中。
  3. 对偏航角和俯仰角进行最大和最小值的限制。
  4. 计算方向向量。

第一步是计算鼠标自上一帧的偏移量。我们必须先在程序中储存上一帧的鼠标位置,我们把它的初始值设置为屏幕的中心(屏幕的尺寸是800x600):

1
float lastX = 400, lastY = 300;

然后在鼠标的回调函数中我们计算当前帧和上一帧鼠标位置的偏移量:

1
2
3
4
5
6
7
8
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
lastX = xpos;
lastY = ypos;

float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;

注意

  • 鼠标坐标计算原点在左下角,渲染窗口原点在左上角。因此y的计算要注意加负号
  • 我们把偏移量乘以了sensitivity(灵敏度)值。如果我们忽略这个值,鼠标移动就会太大了;你可以自己实验一下,找到适合自己的灵敏度值。

接下来我们把偏移量加到全局变量pitch和yaw上:

1
2
yaw   += xoffset;
pitch += yoffset;

第三步,我们需要给摄像机添加一些限制,这样摄像机就不会发生奇怪的移动了(这样也会避免一些奇怪的问题)。

对于俯仰角,要让用户不能看向高于89度的地方(在90度时视角会发生逆转,所以我们把89度作为极限),同样也不允许小于-89度。

这样能够保证用户只能看到天空或脚下,但是不能超越这个限制。我们可以在值超过限制的时候将其改为极限值来实现:

1
2
3
4
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;

注意我们没有给偏航角设置限制,这是因为我们不希望限制用户的水平旋转。当然,给偏航角设置限制也很容易,如果你愿意可以自己实现。


第四也是最后一步,就是通过俯仰角和偏航角来计算以得到真正的方向向量:

1
2
3
4
5
glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);

计算出来的方向向量就会包含根据鼠标移动计算出来的所有旋转了。由于cameraFront向量已经包含在GLM的lookAt函数中,我们这就没什么问题了。


问题:摄像机跳跃

如果你现在运行代码,你会发现在窗口第一次获取焦点的时候摄像机会突然跳一下。

这个问题产生的原因是,在你的鼠标移动进窗口的那一刻,鼠标回调函数就会被调用,这时候的xpos和ypos会等于鼠标刚刚进入屏幕的那个位置。

这通常是一个距离屏幕中心很远的地方,因而产生一个很大的偏移量,所以就会跳了。

我们可以简单的使用一个bool变量检验我们是否是第一次获取鼠标输入,如果是,那么我们先把鼠标的初始位置更新为xpos和ypos值,这样就能解决这个问题;

接下来的鼠标移动就会使用刚进入的鼠标位置坐标来计算偏移量了:

1
2
3
4
5
6
if(firstMouse) // 这个bool变量初始时是设定为true的
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}

最后的代码应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
if(firstMouse)
{
lastX = xpos;
lastY = ypos;
firstMouse = false;
}

float xoffset = xpos - lastX;
float yoffset = lastY - ypos;
lastX = xpos;
lastY = ypos;

float sensitivity = 0.05;
xoffset *= sensitivity;
yoffset *= sensitivity;

yaw += xoffset;
pitch += yoffset;

if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;

glm::vec3 front;
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
front.y = sin(glm::radians(pitch));
front.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
cameraFront = glm::normalize(front);
}

视角缩放

作为我们摄像机系统的一个附加内容,我们还会来实现一个缩放(Zoom)接口。

在之前的教程中我们说视野(Field of View)或fov定义了我们可以看到场景中多大的范围。

当视野变小时,场景投影出来的空间就会减小,产生放大(Zoom In)了的感觉。

我们会使用鼠标的滚轮来放大。与鼠标移动、键盘输入一样,我们需要一个鼠标滚轮的回调函数:

1
2
3
4
5
6
7
8
9
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
if(fov >= 1.0f && fov <= 45.0f)
fov -= yoffset; //滚轮往上滚代表视野放大(等价于yoffset增大,fov减小)
if(fov <= 1.0f)
fov = 1.0f;
if(fov >= 45.0f)
fov = 45.0f;
}

当滚动鼠标滚轮的时候,yoffset值代表我们竖直滚动的大小。

当scroll_callback函数被调用后,我们改变全局变量fov变量的内容。

因为45.0f是默认的视野值,我们将会把缩放级别(Zoom Level)限制在1.0f45.0f


我们现在在每一帧都必须把透视投影矩阵上传到GPU,但现在使用fov变量作为它的视野:

1
projection = glm::perspective(glm::radians(fov), 800.0f / 600.0f, 0.1f, 100.0f);

最后不要忘记注册鼠标滚轮的回调函数:

1
glfwSetScrollCallback(window, scroll_callback);

现在,我们就实现了一个简单的摄像机系统了,它能够让我们在3D环境中自由移动。

第一章小结

https://learnopengl-cn.github.io/01%20Getting%20started/10%20Review/

恭喜您完成了本章的学习,至此为止你应该能够创建一个窗口,创建并且编译着色器,通过缓冲对象或者uniform发送顶点数据,绘制物体,使用纹理,理解向量和矩阵,并且可以综合上述知识创建一个3D场景并可以通过摄像机来移动。

Ch2.光照

2.1 颜色

https://learnopengl-cn.github.io/02%20Lighting/01%20Colors/

在前面的教程中我们已经简要提到过该如何在OpenGL中使用颜色(Color),但是我们至今所接触到的都是很浅层的知识。本节我们将会更深入地讨论什么是颜色,并且还会为接下来的光照(Lighting)教程创建一个场景。


颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,它们通常被缩写为RGB。仅仅用这三个值就可以组合出任意一种颜色。

我们在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(Reflected)颜色。

换句话说,那些不能被物体所吸收(Absorb)的颜色(被拒绝的颜色)就是我们能够感知到的物体的颜色。

  • 例如,太阳光能被看见的白光其实是由许多不同的颜色组合而成的(如下图所示)。
  • 如果我们将白光照在一个珊瑚红色的玩具上,这个珊瑚红色的玩具会吸收白光中除了珊瑚红色以外的所有子颜色,不被吸收的珊瑚红色光被反射到我们的眼中,让这个玩具看起来是珊瑚红色的。
  • image

这些颜色反射的定律被直接地运用在图形领域。

当我们在OpenGL中创建一个光源时,我们希望给光源一个颜色。

在上一段中我们有一个白色的太阳,所以我们也将光源设置为白色。当我们把光源的颜色与物体的颜色值相乘,所得到的就是这个物体所反射的颜色(也就是我们所感知到的颜色)。

让我们再次审视我们的玩具(这一次它还是珊瑚红),看看如何在图形学中计算出它的反射颜色。我们将这两个颜色向量作分量相乘,结果就是最终的颜色向量了:

1
2
3
glm::vec3 lightColor(1.0f, 1.0f, 1.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (1.0f, 0.5f, 0.31f);

我们可以定义物体的颜色为物体从一个光源反射各个颜色分量的大小

现在,如果我们使用绿色的光源又会发生什么呢?

1
2
3
glm::vec3 lightColor(0.0f, 1.0f, 0.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);
glm::vec3 result = lightColor * toyColor; // = (0.0f, 0.5f, 0.0f);

可以看到,并没有红色和蓝色的光让我们的玩具来吸收或反射。

这个玩具吸收了光线中一半的绿色值,但仍然也反射了一半的绿色值。玩具现在看上去是深绿色(Dark-greenish)的。

这些颜色的理论已经足够了,下面我们来构造一个实验用的场景吧。

创建一个光照场景

在接下来的教程中,我们将会广泛地使用颜色来模拟现实世界中的光照效果,创造出一些有趣的视觉效果。

由于我们现在将会使用光源了,我们希望将它们显示为可见的物体,并在场景中至少加入一个物体来测试模拟光照的效果。

  • 首先我们需要一个物体来作为被投光(Cast the light)的对象,我们将使用前面教程中的那个著名的立方体箱子。
  • 我们还需要一个物体来代表光源在3D场景中的位置。简单起见,我们依然使用一个立方体来代表光源

本节无新或重要知识点,详见文档:https://learnopengl-cn.github.io/02%20Lighting/01%20Colors/#_2

2.2 基础光照

冯氏光照模型(Phong Lighting Model)

  • 主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。

下面这张图展示了这些光照分量看起来的样子:

image
  • 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
  • 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
  • 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。

说明:该小节过于基础,只做简单介绍

环境光照

正如你在上一节所学到的,我们使用一个很小的常量(光照)颜色,添加到物体片段的最终颜色中,这样子的话即便场景中没有直接的光源也能看起来存在有一些发散的光。

把环境光照添加到场景里非常简单。我们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色:

1
2
3
4
5
6
7
8
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;

vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
image

漫反射光照

image

计算漫反射光照需要什么?

  • 法向量:一个垂直于顶点表面的向量。
  • 定向的光线:作为光源的位置与片段的位置之间向量差的方向向量。为了计算这个光线,我们需要光的位置向量和片段的位置向量。

法向量

==法向量==是一个垂直于顶点表面的(单位)向量。由于顶点本身并没有表面(它只是空间中一个独立的点),我们利用它周围的顶点来计算出这个顶点的表面。

我们能够使用一个小技巧,使用==叉乘==对立方体所有的顶点计算法向量,但是由于3D立方体不是一个复杂的形状,所以我们可以简单地把法线数据==手工==添加到顶点数据中。

由于我们向顶点数组添加了额外的数据,所以我们应该更新光照的顶点着色器:

1
2
3
4
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
...

现在我们已经向每个顶点添加了一个法向量并更新了顶点着色器,我们还要更新顶点属性指针。

注意,灯使用同样的顶点数组作为它的顶点数据,然而灯的着色器并没有使用新添加的法向量。我们不需要更新灯的着色器或者是属性的配置,但是我们必须至少修改一下顶点属性指针来适应新的顶点数组的大小:

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

我们只想使用每个顶点的前三个float,并且忽略后三个float,所以我们只需要把步长参数改成float大小的6倍就行了。

image

所有光照的计算都是在片段着色器里进行,所以我们需要将法向量由顶点着色器传递到片段着色器。我们这么做:

1
2
3
4
5
6
7
out vec3 Normal;

void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
Normal = aNormal;
}

接下来,在片段着色器中定义相应的输入变量:

1
in vec3 Normal;

准备光线向量数据

我们现在对每个顶点都有了法向量,但是我们仍然需要光源的位置向量和片段的位置向量。

由于光源的位置是一个静态变量,我们可以简单地在片段着色器中把它声明为uniform:

1
uniform vec3 lightPos;

然后在渲染循环中(渲染循环的外面也可以,因为它不会改变)更新uniform。我们使用在前面声明的lightPos向量作为光源位置:

1
lightingShader.setVec3("lightPos", lightPos); //lightingShader是绘制立方体的Shader

最后,我们还需要片段的位置。我们会在世界空间中进行所有的光照计算,因此我们需要一个在世界空间中的顶点位置

我们可以通过把顶点位置属性乘以模型矩阵(不是观察和投影矩阵)来把它变换到世界空间坐标。

这个在顶点着色器中很容易完成,所以我们声明一个输出变量,并计算它的世界空间坐标:

1
2
3
4
5
6
7
8
9
out vec3 FragPos;  
out vec3 Normal;

void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
FragPos = vec3(model * vec4(aPos, 1.0));
Normal = aNormal;
}

最后,在片段着色器中添加相应的输入变量。

1
in vec3 FragPos;

现在,所有需要的变量都设置好了,我们可以在片段着色器中添加光照计算了。

计算漫反射光照

我们需要做的第一件事是计算光源和片段位置之间的==方向向量==。

  • 前面提到,光的方向向量是光源位置向量与片段位置向量之间的向量差
  • 我们同样希望确保所有相关向量最后都转换为单位向量,所以我们把法线和最终的方向向量都进行标准化
1
2
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);

下一步,我们对norm和lightDir向量进行点乘,计算光源对当前片段实际的漫反射影响。结果值再乘以光的颜色,得到漫反射分量。两个向量之间的角度越大,漫反射分量就会越小:

1
2
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;

如果两个向量之间的角度大于90度,点乘的结果就会变成负数,这样会导致漫反射分量变为负数。

为此,我们使用max函数返回两个参数之间较大的参数,从而保证漫反射分量不会变成负数。负数颜色的光照是没有定义的,所以最好避免它,除非你是那种古怪的艺术家。


现在我们有了环境光分量和漫反射分量,我们把它们相加,然后把结果乘以物体的颜色,来获得片段最后的输出颜色。

1
2
vec3 result = (ambient + diffuse) * objectColor;
FragColor = vec4(result, 1.0);
image

法向量的正确计算方法

现在我们已经把法向量从顶点着色器传到了片段着色器。可是,目前片段着色器里的计算都是在世界空间坐标中进行的。

所以,我们是不是应该把法向量也转换为世界空间坐标?基本正确,但是这不是简单地把它乘以一个模型矩阵就能搞定的。

如果模型矩阵执行了==不等比缩放==,顶点的改变会导致法向量不再垂直于表面了。因此,我们不能用这样的模型矩阵来变换法向量。下面的图展示了应用了不等比缩放的模型矩阵对法向量的影响:

![image-20230404000624918](Learn OpenGL.assets/image-20230404000624918.png)

每当我们应用一个不等比缩放时,法向量就不会再垂直于对应的表面了,这样光照就会被破坏。

  • (注意:等比缩放不会破坏法线,因为法线的方向没被改变,仅仅改变了法线的长度,而这很容易通过标准化来修复)

修复这个行为的诀窍是使用一个为法向量专门定制的模型矩阵。这个矩阵称之为法线矩阵(Normal Matrix),它使用了一些线性代数的操作来移除对法向量错误缩放的影响。如果你想知道这个矩阵是如何计算出来的,建议去阅读这个文章

==法线矩阵==被定义为「模型矩阵左上角3x3部分的==逆矩阵的转置矩阵==」。

在顶点着色器中,我们可以使用inverse和transpose函数自己生成这个法线矩阵,这两个函数对所有类型矩阵都有效。注意我们还要把被处理过的矩阵强制转换为3×3矩阵,来保证它失去了位移属性以及能够乘以vec3的法向量。

1
Normal = mat3(transpose(inverse(model))) * aNormal;
image

镜面光照

镜面光照也决定于光的方向向量和物体的法向量,但是它也决定于观察方向

镜面光照决定于表面的反射特性。如果我们把物体表面设想为一面镜子,那么镜面光照最强的地方就是我们看到表面上反射光的地方。你可以在下图中看到效果:

image

我们通过根据法向量翻折入射光的方向来计算反射向量。

然后我们计算反射向量与观察方向的角度差,它们之间夹角越小,镜面光的作用就越大。

由此产生的效果就是,我们看向在入射光在表面的反射方向时,会看到一点高光。

观察向量是我们计算镜面光照时需要的一个额外变量,我们可以使用观察者的世界空间位置和片段的位置来计算它。之后我们计算出镜面光照强度,用它乘以光源的颜色,并将它与环境光照和漫反射光照部分加和。


我们选择在世界空间进行光照计算

要得到观察者的世界空间坐标,我们直接使用摄像机的位置向量即可(它当然就是那个观察者)。那么让我们把另一个uniform添加到片段着色器中,并把摄像机的位置向量传给着色器:

1
uniform vec3 viewPos;
1
lightingShader.setVec3("viewPos", camera.Position);

现在我们已经获得所有需要的变量,可以计算高光强度了。首先,我们定义一个镜面强度(Specular Intensity)变量,给镜面高光一个中等亮度颜色,让它不要产生过度的影响。

1
float specularStrength = 0.5;

下一步,我们计算视线方向向量,和对应的沿着法线轴的反射向量:

1
2
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);

需要注意的是我们对lightDir向量进行了取反。reflect函数要求第一个向量是光源指向片段位置的向量。

  • 但是lightDir当前正好相反,是从片段指向光源(由先前我们计算lightDir向量时,减法的顺序决定)。为了保证我们得到正确的reflect向量,我们通过对lightDir向量取反来获得相反的方向。

剩下要做的是计算镜面分量。下面的代码完成了这件事:

1
2
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = specularStrength * spec * lightColor;

这个32是高光的反光度(Shininess)。一个物体的反光度越高,反射光的能力越强,散射得越少,高光点就会越小。在下面的图片里,你会看到不同反光度的视觉效果影响:

image

剩下的最后一件事情是把它加到环境光分量和漫反射分量里,再用结果乘以物体的颜色:

1
2
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
image

在顶点着色器中实现的冯氏光照模型叫做Gouraud着色(Gouraud Shading),而不是冯氏着色(Phong Shading)。

2.3 材质

https://learnopengl-cn.github.io/02%20Lighting/03%20Materials/

在现实世界里,每个物体会对光产生不同的反应。

有些物体反射光的时候不会有太多的散射(Scatter),因而产生较小的高光点,而有些物体则会散射很多,产生一个有着更大半径的高光点。

如果我们想要在OpenGL中模拟多种类型的物体,我们必须针对每种表面定义不同的材质(Material)属性。


在上一节中,我们定义了一个物体和光的颜色,并结合环境光与镜面强度分量,来决定物体的视觉输出。

当描述一个表面时,我们可以分别为三个光照分量定义一个材质颜色(Material Color):

  • 环境光照(Ambient Lighting)
  • 漫反射光照(Diffuse Lighting)
  • 镜面光照(Specular Lighting)。

通过为每个分量指定一个颜色,我们就能够对表面的颜色输出有细粒度的控制了。

现在,我们再添加一个反光度(Shininess)分量,结合上述的三个颜色,我们就有了全部所需的材质属性了:

1
2
3
4
5
6
7
8
9
#version 330 core
struct Material {
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;
};

uniform Material material;

在片段着色器中,我们创建一个结构体(Struct)来储存物体的材质属性。我们也可以把它们储存为独立的uniform值,但是作为一个结构体来储存会更有条理一些。

我们首先定义结构体的布局(Layout),然后简单地以刚创建的结构体作为类型声明一个uniform变量。

如你所见,我们为冯氏光照模型的每个分量都定义一个颜色向量。

有这4个元素定义一个物体的材质,我们能够模拟很多现实世界中的材质。

image

让我们试着在着色器中实现这样的一个材质系统。

设置材质

我们在片段着色器中创建了一个材质结构体的uniform,所以下面我们希望修改一下光照的计算来遵从新的材质属性。

由于所有材质变量都储存在一个结构体中,我们可以从uniform变量material中访问它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void main()
{
// 环境光
vec3 ambient = lightColor * material.ambient;

// 漫反射
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = lightColor * (diff * material.diffuse);

// 镜面光
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
vec3 specular = lightColor * (spec * material.specular);

vec3 result = ambient + diffuse + specular;
FragColor = vec4(result, 1.0);
}

我们现在可以通过设置适当的uniform来设置应用中物体的材质了。

GLSL中一个结构体在设置uniform时并无任何区别,结构体只是充当uniform变量们的一个命名空间。

所以如果想填充这个结构体的话,我们必须设置每个==单独==的uniform,但要以结构体名为前缀:

1
2
3
4
lightingShader.setVec3("material.ambient",  1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.diffuse", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("material.specular", 0.5f, 0.5f, 0.5f);
lightingShader.setFloat("material.shininess", 32.0f);

我们将环境光和漫反射分量设置成我们想要让物体所拥有的颜色,而将镜面分量设置为一个中等亮度的颜色,我们不希望镜面分量过于强烈。我们仍将反光度保持为32。

现在我们能够轻松地在应用中影响物体的材质了。运行程序,你会得到像这样的结果:

image

不过看起来真的不太对劲?

光的属性

这个物体太亮了。物体过亮的原因是环境光、漫反射和镜面光这三个颜色对任何一个光源都全力反射。

光源对环境光、漫反射和镜面光分量也分别具有不同的强度。

如果我们假设lightColor是vec3(1.0),代码会看起来像这样:

1
2
3
vec3 ambient  = vec3(1.0) * material.ambient;
vec3 diffuse = vec3(1.0) * (diff * material.diffuse);
vec3 specular = vec3(1.0) * (spec * material.specular);

所以物体的每个材质属性对每一个光照分量都返回了最大的强度。

对单个光源来说,这些vec3(1.0)值同样可以对每种光源分别改变,而这通常就是我们想要的。

现在,物体的环境光分量完全地影响了立方体的颜色,可是环境光分量实际上不应该对最终的颜色有这么大的影响,所以我们会将光源的环境光强度设置为一个小一点的值,从而限制环境光颜色:

1
vec3 ambient = vec3(0.1) * material.ambient;

我们可以用同样的方式影响光源的漫反射和镜面光强度。

我们希望为光照属性创建类似材质结构体的东西:

1
2
3
4
5
6
7
8
9
struct Light {
vec3 position;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};

uniform Light light;

一个光源对它的ambient、diffuse和specular光照分量有着不同的强度。

  • 环境光照通常被设置为一个比较低的强度,因为我们不希望环境光颜色太过主导。
  • 光源的漫反射分量通常被设置为我们希望光所具有的那个颜色,通常是一个比较明亮的白色。
  • 镜面光分量通常会保持为vec3(1.0),以最大强度发光。

注意我们也将光源的位置向量加入了结构体。

和材质uniform一样,我们需要更新片段着色器:

1
2
3
vec3 ambient  = light.ambient * material.ambient;
vec3 diffuse = light.diffuse * (diff * material.diffuse);
vec3 specular = light.specular * (spec * material.specular);

我们接下来在应用中设置光照强度:

1
2
3
lightingShader.setVec3("light.ambient",  0.2f, 0.2f, 0.2f);
lightingShader.setVec3("light.diffuse", 0.5f, 0.5f, 0.5f); // 将光照调暗了一些以搭配场景
lightingShader.setVec3("light.specular", 1.0f, 1.0f, 1.0f);

现在我们已经调整了光照对物体材质的影响,我们得到了一个与上一节很相似的视觉效果。但这次我们有了对光照和物体材质的完全掌控:

image

不同的光源颜色

到目前为止,我们都只对光源设置了从白到灰到黑范围内的颜色,这样只会改变物体各个分量的强度,而不是它的真正颜色。

由于现在能够非常容易地访问光照的属性了,我们可以随着时间改变它们的颜色,从而获得一些非常有意思的效果。

我们可以利用sin和glfwGetTime函数改变光源的环境光和漫反射颜色,从而很容易地让光源的颜色随着时间变化:

1
2
3
4
5
6
7
8
9
10
glm::vec3 lightColor;
lightColor.x = sin(glfwGetTime() * 2.0f);
lightColor.y = sin(glfwGetTime() * 0.7f);
lightColor.z = sin(glfwGetTime() * 1.3f);

glm::vec3 diffuseColor = lightColor * glm::vec3(0.5f); // 降低影响
glm::vec3 ambientColor = diffuseColor * glm::vec3(0.2f); // 很低的影响

lightingShader.setVec3("light.ambient", ambientColor);
lightingShader.setVec3("light.diffuse", diffuseColor);

2.4 光照贴图

https://learnopengl-cn.github.io/02%20Lighting/04%20Lighting%20maps/

上一节中的那个材质系统是肯定不够的,它只是一个最简单的模型,所以我们需要拓展之前的系统,引入漫反射镜面光贴图(Map)。

这允许我们对物体的漫反射分量(以及间接地对环境光分量,它们几乎总是一样的)和镜面光分量有着更精确的控制。

漫反射贴图

在光照场景中,它通常叫做一个漫反射贴图(Diffuse Map)(3D艺术家通常都这么叫它),它是一个表现了物体所有的漫反射颜色的纹理图像。

image

在着色器中使用漫反射贴图的方法和纹理教程中是完全一样的。但这次我们会将纹理储存为Material结构体中的一个sampler2D。我们将之前定义的vec3漫反射颜色向量替换为漫反射贴图。

image

我们也移除了环境光材质颜色向量,因为环境光颜色在几乎所有情况下都等于漫反射颜色,所以我们不需要将它们分开储存:

1
2
3
4
5
6
7
struct Material {
sampler2D diffuse;
vec3 specular;
float shininess;
};
...
in vec2 TexCoords;

注意我们将在片段着色器中再次需要纹理坐标,所以我们声明一个额外的输入变量。接下来我们只需要从纹理中采样片段的漫反射颜色值即可:

1
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));

不要忘记将环境光的材质颜色设置为漫反射材质颜色同样的值。

1
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));

这就是使用漫反射贴图的全部步骤了。

为了让它正常工作,我们还需要使用纹理坐标更新顶点数据,将它们作为顶点属性传递到片段着色器,加载材质并绑定材质到合适的纹理单元。


顶点数据现在包含了顶点位置、法向量和立方体顶点处的纹理坐标。让我们更新顶点着色器来以顶点属性的形式接受纹理坐标,并将它们传递到片段着色器中:

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
...
out vec2 TexCoords;

void main()
{
...
TexCoords = aTexCoords;
}

记得去更新两个VAO的顶点属性指针来匹配新的顶点数据,并加载箱子图像为一个纹理。

在绘制箱子之前,我们希望将要用的纹理单元赋值到material.diffuse这个uniform采样器,并绑定箱子的纹理到这个纹理单元:

1
2
3
4
lightingShader.setInt("material.diffuse", 0);
...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);

使用了漫反射贴图之后,细节再一次得到惊人的提升,这次箱子有了光照开始闪闪发光(字面意思也是)了。你的箱子看起来可能像这样:

image

镜面光贴图

你可能会注意到,镜面高光看起来有些奇怪,因为我们的物体大部分都是木头,我们知道木头不应该有这么强的镜面高光的。

我们可以将物体的镜面光材质设置为vec3(0.0)来解决这个问题,但这也意味着箱子钢制的边框将不再能够显示镜面高光了,我们知道钢铁应该是有一些镜面高光的。

所以,我们想要让物体的某些部分以不同的强度显示镜面高光。

个问题看起来和漫反射贴图非常相似。是巧合吗?我想不是。

我们同样可以使用一个专门用于镜面高光的纹理贴图。这也就意味着我们需要生成一个黑白的(如果你想得话也可以是彩色的)纹理,来定义物体每部分的镜面光强度。

image

镜面高光的强度可以通过图像每个像素的亮度来获取。镜面光贴图上的每个像素都可以由一个颜色向量来表示,比如说黑色代表颜色向量vec3(0.0),灰色代表颜色向量vec3(0.5)

在片段着色器中,我们接下来会取样对应的颜色值并将它乘以光源的镜面强度。一个像素越「白」,乘积就会越大,物体的镜面光分量就会越亮。

由于箱子大部分都由木头所组成,而且木头材质应该没有镜面高光,所以漫反射纹理的整个木头部分全部都转换成了黑色。箱子钢制边框的镜面光强度是有细微变化的,钢铁本身会比较容易受到镜面高光的影响,而裂缝则不会。

使用PhotoshopGimp之类的工具,将漫反射纹理转换为镜面光纹理还是比较容易的,只需要剪切掉一些部分,将图像转换为黑白的,并增加亮度/对比度就好了。

采样镜面光贴图

镜面光贴图和其它的纹理非常类似,所以代码也和漫反射贴图的代码很类似。记得要保证正确地加载图像并生成一个纹理对象。

由于我们正在同一个片段着色器中使用另一个纹理采样器,我们必须要对镜面光贴图使用一个不同的纹理单元(见[纹理](https://learnopengl-cn.github.io/01 Getting started/06 Textures/)),所以我们在渲染之前先把它绑定到合适的纹理单元上:

1
2
3
4
lightingShader.setInt("material.specular", 1);
...
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap);

接下来更新片段着色器的材质属性,让其接受一个sampler2D而不是vec3作为镜面光分量:

1
2
3
4
5
struct Material {
sampler2D diffuse;
sampler2D specular;
float shininess;
};

最后我们希望采样镜面光贴图,来获取片段所对应的镜面光强度:

1
2
3
4
vec3 ambient  = light.ambient  * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
FragColor = vec4(ambient + diffuse + specular, 1.0);

如果你现在运行程序的话,你可以清楚地看到箱子的材质现在和真实的钢制边框箱子非常类似了:

image

通过使用漫反射和镜面光贴图,我们可以给相对简单的物体添加大量的细节。

我们甚至可以使用法线/凹凸贴图(Normal/Bump Map)或者反射贴图(Reflection Map)给物体添加更多的细节,但这些将会留到之后的教程中。

把你的箱子给你的朋友或者家人看看,并且坚信我们的箱子有一天会比现在更加漂亮!

2.5 光线投射

https://learnopengl-cn.github.io/02%20Lighting/05%20Light%20casters/

我们目前使用的光照都来自于空间中的一个点。它能给我们不错的效果,但现实世界中,我们有很多种类的光照,每种的表现都不同。

将光投射(Cast)到物体的光源叫做==投光物==(Light Caster)。

在这一节中,我们将会讨论几种不同类型的投光物。学会模拟不同种类的光源是又一个能够进一步丰富场景的工具。

我们首先将会讨论定向光(Directional Light),接下来是点光源(Point Light),它是我们之前学习的光源的拓展,最后我们将会讨论聚光(Spotlight)。在[下一节](https://learnopengl-cn.github.io/02 Lighting/06 Multiple lights/)中我们将讨论如何将这些不同种类的光照类型整合到一个场景之中。

平行光

当一个光源处于很远的地方时,来自光源的每条光线就会近似于互相平行。不论物体和/或者观察者的位置,看起来好像所有的光都来自于同一个方向。

当我们使用一个假设光源处于无限远处的模型时,它就被称为==定向光==,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的。

定向光非常好的一个例子就是太阳。太阳距离我们并不是无限远,但它已经远到在光照计算中可以把它视为无限远了。所以来自太阳的所有光线将被模拟为平行光线,我们可以在下图看到:

image

我们可以定义一个光线方向向量而不是位置向量来模拟一个定向光。着色器的计算基本保持不变,但这次我们将直接使用光的direction向量而不是通过position来计算lightDir向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Light {
// vec3 position; // 使用定向光就不再需要了
vec3 direction;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
}

注意我们首先对light.direction向量取反。我们目前使用的光照计算需求一个从片段光源的光线方向,但人们更习惯定义定向光为一个光源出发的全局方向。

所以我们需要对全局光照方向向量取反来改变它的方向,它现在是一个指向光源的方向向量了。而且,记得对向量进行标准化,假设输入向量为一个单位向量是很不明智的。

最终的lightDir向量将和以前一样用在漫反射和镜面光计算中。

image

点光源

定向光对于照亮整个场景的全局光源是非常棒的,但除了定向光之外我们也需要一些分散在场景中的点光源(Point Light)。

点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离逐渐衰减。

想象作为投光物的灯泡和火把,它们都是点光源。

image

衰减

随着光线传播距离的增长逐渐削减光的强度通常叫做衰减(Attenuation)。

随距离减少光强度的一种方式是使用一个线性方程。这样的方程能够随着距离的增长线性地减少光的强度,从而让远处的物体更暗。

然而,这样的线性方程通常会看起来比较假。

在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。

所以,我们需要一个不同的公式来减少光的强度。

幸运的是一些聪明的人已经帮我们解决了这个问题。下面这个公式根据片段距光源的距离计算了衰减值,之后我们会将它乘以光的强度向量:

image

在这里dd代表了片段距光源的距离。接下来为了计算衰减值,我们定义3个(可配置的)项:常数项$K_c$、一次项$K_l$和二次项$K_q$。

  • 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
  • 一次项会与距离值相乘,以线性的方式减少强度。
  • 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了。

由于二次项的存在,光线会在大部分时候以线性的方式衰退,直到距离变得足够大,让二次项超过一次项,光的强度会以更快的速度下降。

这样的结果就是,光在近距离时亮度很高,但随着距离变远亮度迅速降低,最后会以更慢的速度减少亮度。

下面这张图显示了在100的距离内衰减的效果:

image

你可以看到光在近距离的时候有着最高的强度,但随着距离增长,它的强度明显减弱,并缓慢地在距离大约100的时候强度接近0。这正是我们想要的。

选择正确的值

但是,该对这三个项设置什么值呢?

正确地设定它们的值取决于很多因素:环境、希望光覆盖的距离、光的类型等。

在大多数情况下,这都是经验的问题,以及适量的调整。

下面这个表格显示了模拟一个(大概)真实的,覆盖特定半径(距离)的光源时,这些项可能取的一些值。

image

你可以看到,常数项KcKc在所有的情况下都是1.0。一次项Kl为了覆盖更远的距离通常都很小,二次项Kq甚至更小。

尝试对这些值进行实验,看看它们在你的实现中有什么效果。在我们的环境中,32到100的距离对大多数的光源都足够了。

实现衰减

为了实现衰减,在片段着色器中我们还需要三个额外的值:也就是公式中的常数项、一次项和二次项。

它们最好储存在之前定义的Light结构体中。注意我们使用上一节中计算lightDir的方法,而不是上面定向光部分的。

1
2
3
4
5
6
7
8
9
10
11
struct Light {
vec3 position;

vec3 ambient;
vec3 diffuse;
vec3 specular;

float constant;
float linear;
float quadratic;
};

然后我们将在OpenGL中设置这些项:我们希望光源能够覆盖50的距离,所以我们会使用表格中对应的常数项、一次项和二次项:

1
2
3
lightingShader.setFloat("light.constant",  1.0f);
lightingShader.setFloat("light.linear", 0.09f);
lightingShader.setFloat("light.quadratic", 0.032f);

在片段着色器中实现衰减还是比较直接的:我们根据公式计算衰减值,之后再分别乘以环境光、漫反射和镜面光分量。

我们可以通过获取片段和光源之间的向量差,并获取结果向量的长度作为距离项。我们可以使用GLSL内建的length函数来完成这一点:

1
2
3
float distance    = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));

接下来,我们将包含这个衰减值到光照计算中,将它分别乘以环境光、漫反射和镜面光颜色。

image
1
2
3
ambient  *= attenuation; 
diffuse *= attenuation;
specular *= attenuation;

如果你运行程序的话,你会获得这样的结果:

image

你可以看到,只有前排的箱子被照亮的,距离最近的箱子是最亮的。后排的箱子一点都没有照亮,因为它们离光源实在是太远了。

聚光

我们要讨论的最后一种类型的光是聚光(Spotlight)。

聚光是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线。

这样的结果就是只有在聚光方向的特定半径内的物体才会被照亮,其它的物体都会保持黑暗。

聚光很好的例子就是路灯或手电筒。


OpenGL中聚光是用==一个世界空间位置==、==一个方向==和==一个切光角==(Cutoff Angle)来表示的,切光角指定了聚光的半径(译注:是圆锥的半径不是距光源距离那个半径)。

对于每个片段,我们会计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话,我们就会相应地照亮片段。下面这张图会让你明白聚光是如何工作的:

image
  • LightDir:从片段指向光源的向量。
  • SpotDir:聚光所指向的方向。
  • Phiϕ:指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。
  • Thetaθ:LightDir向量和SpotDir向量之间的夹角。在聚光内部的话θ值应该比ϕ值小。

所以我们要做的就是计算LightDir向量和SpotDir向量之间的点积(还记得它会返回两个单位向量夹角的余弦值吗?),并将它与切光角ϕ值对比。

你现在应该了解聚光究竟是什么了,下面我们将以手电筒的形式创建一个聚光。

手电筒

手电筒(Flashlight)是一个位于观察者位置的聚光,通常它都会瞄准玩家视角的正前方。

基本上说,手电筒就是普通的聚光,但它的位置和方向会随着玩家的位置和朝向不断更新。

所以,在片段着色器中我们需要的值有聚光的位置向量(来计算光的方向向量)、聚光的方向向量和一个切光角

我们可以将它们储存在Light结构体中:

1
2
3
4
5
6
struct Light {
vec3 position;
vec3 direction;
float cutOff;
...
};

接下来我们将合适的值传到着色器中:

1
2
3
lightingShader.setVec3("light.position",  camera.Position);
lightingShader.setVec3("light.direction", camera.Front);
lightingShader.setFloat("light.cutOff", glm::cos(glm::radians(12.5f)));

你可以看到,我们并没有给切光角设置一个角度值,反而是用角度值计算了一个余弦值,将余弦结果传递到片段着色器中。

  • 这样做的原因是在片段着色器中,我们会计算LightDirSpotDir向量的点积,这个点积返回的将是一个余弦值而不是角度值,所以我们不能直接使用角度值和余弦值进行比较。
  • 这两个角度都由余弦角来表示了,我们可以直接对它们进行比较而不用进行任何开销高昂的计算。

接下来就是计算θ值,并将它和切光角ϕ对比,来决定是否在聚光的内部:

1
2
3
4
5
6
7
8
float theta = dot(lightDir, normalize(-light.direction));

if(theta > light.cutOff)
{
// 执行光照计算
}
else // 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);

我们首先计算了lightDir和取反的direction向量(取反的是因为我们想让向量指向光源而不是从光源出发)之间的点积。记住要对所有的相关向量标准化。

运行程序,你将会看到一个聚光,它仅会照亮聚光圆锥内的片段。看起来像是这样的:

image

但这仍看起来有些假,主要是因为聚光有一圈硬边。当一个片段遇到聚光圆锥的边缘时,它会完全变暗,没有一点平滑的过渡。一个真实的聚光将会在边缘处逐渐减少亮度。

平滑/软化边缘

为了创建一种看起来边缘平滑的聚光,我们需要模拟聚光有一个==内圆锥==(Inner Cone)和一个==外圆锥==(Outer Cone)。

我们可以将内圆锥设置为上一部分中的那个圆锥,但我们也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界。


为了创建一个外圆锥,我们只需要再定义一个余弦值来代表聚光方向向量和外圆锥向量(等于它的半径)的夹角。

然后,如果一个片段处于内外圆锥之间,将会给它计算出一个0.0到1.0之间的强度值。

如果片段在内圆锥之内,它的强度就是1.0,如果在外圆锥之外强度值就是0.0。

我们可以用下面这个公式来计算这个值:

image

这里ϵ(Epsilon)是内(ϕ)和外圆锥(γ)之间的余弦值差(ϵ=ϕ−γ)。最终的I值就是在当前片段聚光的强度。


我们现在有了一个在聚光外是负的,在内圆锥内大于1.0的,在边缘处于两者之间的强度值了。

如果我们正确地==约束==(Clamp)这个值,在片段着色器中就不再需要if-else了,我们能够使用计算出来的强度值直接乘以光照分量:

1
2
3
4
5
6
7
8
float theta     = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
...
// 将不对环境光做出影响,让它总是能有一点光
diffuse *= intensity;
specular *= intensity;
...

注意我们使用了clamp函数,它把第一个参数约束(Clamp)在了0.0到1.0之间。这保证强度值不会在[0, 1]区间之外。

确定你将outerCutOff值添加到了Light结构体之中,并在程序中设置它的uniform值。下面的图片中,我们使用的内切光角是12.5,外切光角是17.5:

image

2.6 多光源

https://learnopengl-cn.github.io/02%20Lighting/06%20Multiple%20lights/

在这一节中,我们将结合之前学过的所有知识,创建一个包含六个光源的场景。

  • 我们将模拟一个类似太阳的定向光(Directional Light)光源,四个分散在场景中的点光源(Point Light),以及一个手电筒(Flashlight)。

为了在场景中使用多个光源,我们希望将光照计算封装到GLSL函数中。

  • 这样做的原因是,每一种光源都需要一种不同的计算方法,而一旦我们想对多个光源进行光照计算时,代码很快就会变得非常复杂。
  • 如果我们只在main函数中进行所有的这些计算,代码很快就会变得难以理解。

GLSL中的函数和C函数很相似,它有一个函数名、一个返回值类型,如果函数不是在main函数之前声明的,我们还必须在代码文件顶部声明一个原型。

我们对每个光照类型都创建一个不同的函数:定向光、点光源和聚光。


当我们在场景中使用多个光源时,通常使用以下方法:我们需要有一个单独的颜色向量代表片段的输出颜色。

对于每一个光源,它对片段的贡献颜色将会加到片段的输出颜色向量上。

所以场景中的每个光源都会计算它们各自对片段的影响,并结合为一个最终的输出颜色。大体的结构会像是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
out vec4 FragColor;

void main()
{
// 定义一个输出颜色值
vec3 output;
// 将定向光的贡献加到输出中
output += someFunctionToCalculateDirectionalLight();
// 对所有的点光源也做相同的事情
for(int i = 0; i < nr_of_point_lights; i++)
output += someFunctionToCalculatePointLight();
// 也加上其它的光源(比如聚光)
output += someFunctionToCalculateSpotLight();

FragColor = vec4(output, 1.0);
}

实际的代码对每一种实现都可能不同,但大体的结构都是差不多的。我们定义了几个函数,用来计算每个光源的影响,并将最终的结果颜色加到输出颜色向量上。

例如,如果两个光源都很靠近一个片段,那么它们所结合的贡献将会形成一个比单个光源照亮时更加明亮的片段。

说明:该节无过多新知识点,只是对上一节内容的封装,故选择性记录

点光源

主要演示宏定义和uniform数组的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
struct PointLight {
vec3 position;

float constant;
float linear;
float quadratic;

vec3 ambient;
vec3 diffuse;
vec3 specular;
};
#define NR_POINT_LIGHTS 4
uniform PointLight pointLights[NR_POINT_LIGHTS];

设置定向光结构体的uniform应该非常熟悉了,但是你可能会在想我们该如何设置点光源的uniform值,因为点光源的uniform现在是一个PointLight的数组了。这并不是我们以前讨论过的话题。

很幸运的是,这并不是很复杂,设置一个结构体数组的uniform和设置一个结构体的uniform是很相似的,但是这一次在访问uniform位置的时候,我们需要定义对应的数组下标值:

1
lightingShader.setFloat("pointLights[0].constant", 1.0f);

在这里我们索引了pointLights数组中的第一个PointLight,并获取了constant变量的位置。

但这也意味着不幸的是我们必须对这四个点光源手动设置uniform值,这让点光源本身就产生了28个uniform调用,非常冗长。

你也可以尝试将这些抽象出去一点,定义一个点光源类,让它来为你设置uniform值,但最后你仍然要用这种方式设置所有光源的uniform值。

复习

https://learnopengl-cn.github.io/02%20Lighting/07%20Review/

总的来说我们在学习光照教程的时候关于OpenGL本身并没有什么新东西,除了像访问uniform数组这样细枝末节的知识。

目前为止的所有教程都是关于使用一些技巧或者公式来操作着色器,达到真实的光照效果。

这再一次向你展示了着色器的威力。着色器是非常灵活的,你也亲眼见证了我们仅仅使用一些3D向量和可配置的变量就能够创造出惊人的图像这一点。

在前面的几个教程中,你学习了颜色、冯氏光照模型(包括环境光照、漫反射光照和镜面光照)、物体的材质、可配置的光照属性、漫反射和镜面光贴图、不同种类的光,并且学习了怎样将所有所学知识融会贯通,合并到一个程序当中。

记得去实验一下不同的光照、材质颜色、光照属性,并且试着利用你无穷的创造力创建自己的环境。

Ch3.模型加载

3.1 Assimp

https://learnopengl-cn.github.io/03%20Model%20Loading/01%20Assimp/

和箱子对象不同,我们不太能够对像是房子、汽车或者人形角色这样的复杂形状手工定义所有的顶点、法线和纹理坐标。

我们想要的是将这些模型(Model)导入(Import)到程序当中。模型通常都由3D艺术家在Blender3DS Max或者Maya这样的工具中精心制作。


这些所谓的3D建模工具(3D Modeling Tool)可以让艺术家创建复杂的形状,并使用一种叫做UV映射(uv-mapping)的手段来应用贴图。

这些工具将会在导出到模型文件的时候自动生成所有的顶点坐标、顶点法线以及纹理坐标。这样子艺术家们即使不了解图形技术细节的情况下,也能拥有一套强大的工具来构建高品质的模型了。

所有的技术细节都隐藏在了导出的模型文件中。但是,作为图形开发者,我们就必须要了解这些技术细节了。


所以,我们的工作就是解析这些导出的模型文件以及提取所有有用的信息,将它们储存为OpenGL能够理解的格式。

一个很常见的问题是,模型的文件格式有很多种,每一种都会以它们自己的方式来导出模型数据。

  • 像是Wavefront的.obj这样的模型格式,只包含了模型数据以及材质信息,像是模型颜色和漫反射/镜面光贴图。
    • Wavefront的.obj格式通常被认为是一个易于解析的模型格式。
  • 而以XML为基础的Collada文件格式则非常的丰富,包含模型、光照、多种材质、动画数据、摄像机、完整的场景信息等等。

总而言之,不同种类的文件格式有很多,它们之间通常并没有一个通用的结构。

所以如果我们想从这些文件格式中导入模型的话,我们必须要去自己对每一种需要导入的文件格式写一个导入器。

很幸运的是,正好有一个库专门处理这个问题。

模型加载库

一个非常流行的模型导入库是Assimp,它是Open Asset Import Library(开放的资产导入库)的缩写。

Assimp能够导入很多种不同的模型文件格式(并也能够导出部分的格式),它会将所有的模型数据加载至Assimp的通用数据结构中。

当Assimp加载完模型之后,我们就能够从Assimp的数据结构中提取我们所需的所有数据了。

由于Assimp的数据结构保持不变,不论导入的是什么种类的文件格式,它都能够将我们从这些不同的文件格式中抽象出来,用同一种方式访问我们需要的数据。


当使用Assimp导入一个模型的时候,它通常会将整个模型加载进一个场景(Scene)对象,它会包含导入的模型/场景中的所有数据。

Assimp会将场景载入为一系列的节点(Node),每个节点包含了场景对象中所储存数据的索引,每个节点都可以有任意数量的子节点。

Assimp数据结构的(简化)模型如下:

image
  • 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中。Scene对象也包含了场景根节点的引用。
  • 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引。
    • Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引。
  • 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
  • 一个网格包含了多个面。Face代表的是物体的渲染图元(Primitive)(三角形、方形、点)。
    • 一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的(见[你好,三角形](https://learnopengl-cn.github.io/01 Getting started/04 Hello Triangle/))。
  • 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)。

所以,我们需要做的第一件事是将一个物体加载到Scene对象中,遍历节点,获取对应的Mesh对象(我们需要递归搜索每个节点的子节点),并处理每个Mesh对象来获取顶点数据、索引以及它的材质属性。

最终的结果是一系列的网格数据,我们会将它们包含在一个Model对象中。

image

在[下一节](https://learnopengl-cn.github.io/03 Model Loading/02 Mesh/)中,我们将创建我们自己的Model和Mesh类来加载并使用刚刚介绍的结构储存导入后的模型。

如果我们想要绘制一个模型,我们不需要将整个模型渲染为一个整体,只需要渲染组成模型的每个独立的网格就可以了。

然而,在我们开始导入模型之前,我们首先需要将Assimp包含到我们的工程当中。

完善:Assimp数据结构

image

该图均为本人一己之见,便于理解所做,便于体系自洽所做,解释:

  1. 在3.3 模型小节:加载自定义模型过程中,在构建阶段,就把所有Assimp定义的子模型Meshes统统加载到该自定义模型的Mesh数组中
    • 通俗理解:一个模型相当于上图的根节点,在加载模型的时候,一口气把所有子节点的网格数组全部汇总到根节点当中
  2. 对于面数组,每个面元素是由一个图元组成(如果图元为三角形,一个面元素就由3个索引值构成)

构建Assimp

你可以在Assimp的下载页面中选择相应的版本。在写作时使用的Assimp最高版本为3.1.1。

我们建议你自己编译Assimp库,因为它们的预编译库在大部分系统上都是不能运行的。

3.2 网格

https://learnopengl-cn.github.io/03%20Model%20Loading/02%20Mesh/

通过使用Assimp,我们可以加载不同的模型到程序中,但是载入后它们都被储存为Assimp的数据结构。

我们最终仍要将这些数据转换为OpenGL能够理解的格式,这样才能渲染这个物体。

我们从上一节中学到,网格(Mesh)代表的是单个的可绘制实体,我们现在先来定义一个我们自己的网格类。

  • 一个网格应该至少需要一系列的顶点,每个顶点包含一个位置向量、一个法向量和一个纹理坐标向量
  • 一个网格还应该包含用于索引绘制的索引以及纹理形式的材质数据(漫反射/镜面光贴图)。

既然我们有了一个网格类的最低需求,我们可以在OpenGL中定义一个顶点了:

1
2
3
4
5
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
};

我们将所有需要的向量储存到一个叫做Vertex的结构体中,我们可以用它来索引每个顶点属性。


除了Vertex结构体之外,我们还需要将纹理数据整理到一个Texture结构体中。

1
2
3
4
struct Texture {
unsigned int id;
string type;
};

我们储存了纹理的id以及它的类型,比如是漫反射贴图或者是镜面光贴图。


知道了顶点和纹理的实现,我们可以开始定义网格类的结构了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Mesh {
public:
/* 网格数据 */
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
/* 函数 */
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
void Draw(Shader shader);
private:
/* 渲染数据 */
unsigned int VAO, VBO, EBO;
/* 函数 */
void setupMesh();
};

你可以看到这个类并不复杂。在构造器中,我们将所有必须的数据赋予了网格,我们在setupMesh函数中初始化缓冲,并最终使用Draw函数来绘制网格。

注意我们将一个着色器传入了Draw函数中,将着色器传入网格类中可以让我们在绘制之前设置一些uniform(像是链接采样器到纹理单元)。

构造器的内容非常易于理解。我们只需要使用构造器的参数设置类的公有变量就可以了。我们在构造器中还调用了setupMesh函数:

1
2
3
4
5
6
7
8
Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
{
this->vertices = vertices;
this->indices = indices;
this->textures = textures;

setupMesh();
}

这里没什么可说的。我们接下来讨论setupMesh函数。

初始化

由于有了构造器,我们现在有一大列的网格数据用于渲染。在此之前我们还必须配置正确的缓冲,并通过顶点属性指针定义顶点着色器的布局。

现在你应该对这些概念都很熟悉了,但我们这次会稍微有一点变动,使用结构体中的顶点数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void setupMesh()
{
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
glGenBuffers(1, &EBO);

glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);

glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);

glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int),
&indices[0], GL_STATIC_DRAW);

// 顶点位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
// 顶点法线
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
// 顶点纹理坐标
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));

glBindVertexArray(0);
}

代码应该和你所想得没什么不同,但有了Vertex结构体的帮助,我们使用了一些小技巧。

技巧1

C++结构体有一个很棒的特性,它们的内存布局是连续的(Sequential)。

也就是说,如果我们将结构体作为一个数据数组使用,那么它将会以顺序排列结构体的变量,这将会直接转换为我们在数组缓冲中所需要的float(实际上是字节)数组。

比如说,如果我们有一个填充后的Vertex结构体,那么它的内存布局将会等于:

1
2
3
4
5
Vertex vertex;
vertex.Position = glm::vec3(0.2f, 0.4f, 0.6f);
vertex.Normal = glm::vec3(0.0f, 1.0f, 0.0f);
vertex.TexCoords = glm::vec2(1.0f, 0.0f);
// = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];

由于有了这个有用的特性,我们能够直接传入一大列的Vertex结构体的指针作为缓冲的数据,它们将会完美地转换为glBufferData所能用的参数:

1
glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW); //重点在:vertices.size() * sizeof(Vertex)

自然sizeof运算也可以用在结构体上来计算它的字节大小。这个应该是32字节的(8个float * 每个4字节)。

技巧2

结构体的另外一个很好的用途是它的预处理指令offsetof(s, m)

  • 第一个参数是一个结构体
  • 第二个参数是这个结构体中变量的名字。

这个宏会返回那个变量距结构体头部的字节偏移量(Byte Offset)。

这正好可以用在定义glVertexAttribPointer函数中的偏移参数:

1
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal)); 

偏移量现在是使用offsetof来定义了,在这里它会将法向量的字节偏移量设置为结构体中法向量的偏移量,也就是3个float,即12字节。

注意,我们同样将步长参数设置为了Vertex结构体的大小。

好处

使用这样的一个结构体不仅能够提供可读性更高的代码,也允许我们很容易地拓展这个结构。

如果我们希望添加另一个顶点属性,我们只需要将它添加到结构体中就可以了。由于它的灵活性,渲染的代码不会被破坏。

渲染

我们需要为Mesh类定义最后一个函数,它的Draw函数。

在真正渲染这个网格之前,我们需要在调用glDrawElements函数之前先绑定相应的纹理。

问题:我们一开始并不知道这个网格(如果有的话)有多少纹理、纹理是什么类型的。所以我们该如何在着色器中设置纹理单元和采样器呢?

  • 为了解决这个问题,我们需要设定一个命名标准:每个漫反射纹理被命名为texture_diffuseN,每个镜面光纹理应该被命名为texture_specularN,其中N的范围是1到纹理采样器最大允许的数字。

  • 比如说我们对某一个网格有3个漫反射纹理,2个镜面光纹理,它们的纹理采样器应该之后会被调用:

  • uniform sampler2D texture_diffuse1;
    uniform sampler2D texture_diffuse2;
    uniform sampler2D texture_diffuse3;
    uniform sampler2D texture_specular1;
    uniform sampler2D texture_specular2;
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33

    根据这个标准,我们可以在着色器中定义任意需要数量的纹理采样器,如果一个网格真的包含了(这么多)纹理,我们也能知道它们的名字是什么。

    根据这个标准,我们也能在一个网格中处理任意数量的纹理,开发者也可以自由选择需要使用的数量,他只需要定义正确的采样器就可以了。

    最终的渲染代码是这样的:

    ```c++
    void Draw(Shader shader)
    {
    unsigned int diffuseNr = 1;
    unsigned int specularNr = 1;
    for(unsigned int i = 0; i < textures.size(); i++)
    {
    glActiveTexture(GL_TEXTURE0 + i); // 在绑定之前激活相应的纹理单元
    // 获取纹理序号(diffuse_textureN 中的 N)
    string number;
    string name = textures[i].type;
    if(name == "texture_diffuse")
    number = std::to_string(diffuseNr++);
    else if(name == "texture_specular")
    number = std::to_string(specularNr++);

    shader.setInt(("material." + name + number).c_str(), i);
    glBindTexture(GL_TEXTURE_2D, textures[i].id);
    }
    glActiveTexture(GL_TEXTURE0);

    // 绘制网格
    glBindVertexArray(VAO);
    glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
    glBindVertexArray(0);
    }

我们首先计算了每个纹理类型的N-分量,并将其拼接到纹理类型字符串上,来获取对应的uniform名称。

接下来我们查找对应的采样器,将它的位置值设置为当前激活的纹理单元,并绑定纹理。这也是我们在Draw函数中需要着色器的原因。

我们也将"material."添加到了最终的uniform名称中,因为我们希望将纹理储存在一个材质结构体中(这在每个实现中可能都不同)。

3.3 模型

https://learnopengl-cn.github.io/03%20Model%20Loading/03%20Model/

这个教程的目标是创建另一个类来完整地表示一个模型,或者说是包含多个网格,甚至是多个物体的模型。

一个包含木制阳台、塔楼、甚至游泳池的房子可能仍会被加载为一个模型。我们会使用Assimp来加载模型,并将它转换(Translate)至多个在[上一节](https://learnopengl-cn.github.io/03 Model Loading/02 Mesh/)中创建的Mesh对象。

事不宜迟,我会先把Model类的结构给你:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Model 
{
public:
/* 函数 */
Model(char *path)
{
loadModel(path);
}
void Draw(Shader shader);
private:
/* 模型数据 */
vector<Mesh> meshes;
string directory;
/* 函数 */
void loadModel(string path);
void processNode(aiNode *node, const aiScene *scene);
Mesh processMesh(aiMesh *mesh, const aiScene *scene);
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type,
string typeName);
};

Model类包含了一个Mesh对象的vector(译注:这里指的是C++中的vector模板类,之后遇到均不译)

构造器需要我们给它一个文件路径。在构造器中,它会直接通过loadModel来加载文件。

我们还将储存文件路径的目录,在之后加载纹理的时候还会用到它。

Draw函数没有什么特别之处,基本上就是遍历了所有网格,并调用它们各自的Draw函数。

1
2
3
4
5
void Draw(Shader shader)
{
for(unsigned int i = 0; i < meshes.size(); i++)
meshes[i].Draw(shader);
}

导入3D模型到OpenGL

要想导入一个模型,并将它转换到我们自己的数据结构中的话,首先我们需要包含Assimp对应的头文件,这样编译器就不会抱怨我们了。

1
2
3
#include <assimp/Importer.hpp>
#include <assimp/scene.h>
#include <assimp/postprocess.h>

首先需要调用的函数是loadModel,它会从构造器中直接调用。

在loadModel中,我们使用Assimp来加载模型至Assimp的一个叫做scene的数据结构中,这是Assimp数据接口的根对象。

  • 一旦我们有了这个场景对象,我们就能访问到加载后的模型中所有所需的数据了。

Assimp很棒的一点在于,它抽象掉了加载不同文件格式的所有技术细节,只需要一行代码就能完成所有的工作:

1
2
Assimp::Importer importer;
const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);

我们首先声明了Assimp命名空间内的一个Importer,之后调用了它的ReadFile函数。

  • 这个函数需要一个文件路径
  • 它的第二个参数是一些后期处理(Post-processing)的选项。

除了加载文件之外,Assimp允许我们设定一些选项来强制它对导入的数据做一些额外的计算或操作。

  • 通过设定aiProcess_Triangulate,我们告诉Assimp,如果模型不是(全部)由三角形组成,它需要将模型所有的图元形状变换为三角形。
  • aiProcess_FlipUVs将在处理的时候翻转y轴的纹理坐标(你可能还记得我们在[纹理](https://learnopengl-cn.github.io/01 Getting started/06 Textures/)教程中说过,在OpenGL中大部分的图像的y轴都是反的,所以这个后期处理选项将会修复这个)。

其它一些比较有用的选项有:

  • aiProcess_GenNormals:如果模型不包含法向量的话,就为每个顶点创建法线。
  • aiProcess_SplitLargeMeshes:将比较大的网格分割成更小的子网格,如果你的渲染有最大顶点数限制,只能渲染较小的网格,那么它会非常有用。
  • aiProcess_OptimizeMeshes:和上个选项相反,它会将多个小网格拼接为一个大的网格,减少绘制调用从而进行优化。

Assimp提供了很多有用的后期处理指令,你可以在这里找到全部的指令。

实际上使用Assimp加载模型是非常容易的(你也可以看到)。困难的是之后使用返回的场景对象将加载的数据转换到一个Mesh对象的数组。


加载模型

完整的loadModel函数将会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void loadModel(string path)
{
Assimp::Importer import;
const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);

if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode)
{
cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
return;
}
directory = path.substr(0, path.find_last_of('/'));

processNode(scene->mRootNode, scene);
}

在我们加载了模型之后,我们会检查场景和其根节点不为null,并且检查了它的一个标记(Flag),来查看返回的数据是不是不完整的。

如果遇到了任何错误,我们都会通过导入器的GetErrorString函数来报告错误并返回。

我们也获取了文件路径的目录路径。

如果什么错误都没有发生,我们希望处理场景中的所有节点,所以我们将第一个节点(根节点)传入了递归的processNode函数。


加载所有子模型

你可能还记得Assimp的结构中,每个节点包含了一系列的网格索引,每个索引指向场景对象中的那个特定网格。

我们接下来就想去获取这些网格索引,获取每个网格,处理每个网格,接着对每个节点的子节点重复这一过程。

processNode函数的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void processNode(aiNode *node, const aiScene *scene)
{
// 处理节点所有的网格(如果有的话)
for(unsigned int i = 0; i < node->mNumMeshes; i++)
{
aiMesh *mesh = scene->mMeshes[node->mMeshes[i]]; //当前模型网格数组的其中一个网格数据
meshes.push_back(processMesh(mesh, scene)); //加载网格数据,汇总至meshes
}
// 接下来对它的子节点重复这一过程
for(unsigned int i = 0; i < node->mNumChildren; i++)
{
processNode(node->mChildren[i], scene);
}
}

我们首先检查每个节点的网格索引,并索引场景的mMeshes数组来获取对应的网格。

返回的网格将会传递到processMesh函数中,它会返回一个Mesh对象,我们可以将它存储在meshes列表/vector。

所有网格都被处理之后,我们会遍历节点的所有子节点,并对它们调用相同的processMesh函数。

当一个节点不再有任何子节点之后,这个函数将会停止执行。

下一步就是将Assimp的数据解析到上一节中创建的Mesh类中。

从Assimp到网格

加载一个网格

将一个aiMesh对象转化为我们自己的网格对象不是那么困难。我们要做的只是访问网格的相关属性并将它们储存到我们自己的对象中。processMesh函数的大体结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Mesh processMesh(aiMesh *mesh, const aiScene *scene)
{
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;

for(unsigned int i = 0; i < mesh->mNumVertices; i++)
{
Vertex vertex;
// 处理顶点位置、法线和纹理坐标
...
vertices.push_back(vertex);
}
// 处理索引
...
// 处理材质
if(mesh->mMaterialIndex >= 0)
{
...
}

return Mesh(vertices, indices, textures);
}

处理网格的过程主要有三部分:获取所有的顶点数据,获取它们的网格索引,并获取相关的材质数据。

处理后的数据将会储存在三个vector当中,我们会利用它们构建一个Mesh对象,并返回它到函数的调用者那里。


获取==顶点数据==非常简单,我们定义了一个Vertex结构体,我们将在每个迭代之后将它加到vertices数组中。

我们会遍历网格中的所有顶点(使用mesh->mNumVertices来获取)。

在每个迭代中,我们希望使用所有的相关数据填充这个结构体。顶点的位置是这样处理的:

1
2
3
4
5
glm::vec3 vector; 
vector.x = mesh->mVertices[i].x;
vector.y = mesh->mVertices[i].y;
vector.z = mesh->mVertices[i].z;
vertex.Position = vector;

注意我们为了传输Assimp的数据,我们定义了一个vec3的临时变量。

使用这样一个临时变量的原因是Assimp对向量、矩阵、字符串等都有自己的一套数据类型,它们并不能完美地转换到GLM的数据类型中。

Assimp将它的顶点位置数组叫做mVertices,这其实并不是那么直观。

处理法线的步骤也是差不多的:

1
2
3
4
vector.x = mesh->mNormals[i].x;
vector.y = mesh->mNormals[i].y;
vector.z = mesh->mNormals[i].z;
vertex.Normal = vector;

==纹理坐标==的处理也大体相似,但Assimp允许一个模型在一个顶点上有最多8个不同的纹理坐标,我们不会用到那么多,我们只关心第一组纹理坐标。

我们同样也想检查网格是否真的包含了纹理坐标(可能并不会一直如此)

1
2
3
4
5
6
7
8
9
if(mesh->mTextureCoords[0]) // 网格是否有纹理坐标?
{
glm::vec2 vec;
vec.x = mesh->mTextureCoords[0][i].x;
vec.y = mesh->mTextureCoords[0][i].y;
vertex.TexCoords = vec;
}
else
vertex.TexCoords = glm::vec2(0.0f, 0.0f);

vertex结构体现在已经填充好了需要的顶点属性,我们会在迭代的最后将它压入vertices这个vector的尾部。这个过程会==对每个网格的顶点都重复一遍==。


==索引==

Assimp的接口定义了每个网格都有一个面(Face)数组,每个面代表了一个图元,在我们的例子中(由于使用了aiProcess_Triangulate选项)它总是==三角形==。

一个面包含了多个索引,它们定义了在每个图元中,我们应该绘制哪个顶点,并以什么顺序绘制

所以如果我们遍历了所有的面,并储存了面的索引到indices这个vector中就可以了。

1
2
3
4
5
6
for(unsigned int i = 0; i < mesh->mNumFaces; i++)
{
aiFace face = mesh->mFaces[i];
for(unsigned int j = 0; j < face.mNumIndices; j++) //图元是三角形:face.mNumIndices=3
indices.push_back(face.mIndices[j]);
}

所有的外部循环都结束了,我们现在有了一系列的顶点和索引数据,它们可以用来通过glDrawElements函数来绘制网格。


==材质==

和节点一样,一个网格只包含了一个指向材质对象的索引。

如果想要获取网格真正的材质,我们还需要索引场景的mMaterials数组。

网格材质索引位于它的mMaterialIndex属性中,我们同样可以用它来检测一个网格是否包含有材质:

1
2
3
4
5
6
7
8
9
10
if(mesh->mMaterialIndex >= 0)
{
aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
vector<Texture> diffuseMaps = loadMaterialTextures(material,
aiTextureType_DIFFUSE, "texture_diffuse");
textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
vector<Texture> specularMaps = loadMaterialTextures(material,
aiTextureType_SPECULAR, "texture_specular");
textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
}

我们首先从场景的mMaterials数组中获取aiMaterial对象。

接下来我们希望加载网格的漫反射和/或镜面光贴图。

  • 一个材质对象的内部对每种纹理类型都存储了一个纹理位置数组。
  • 不同的纹理类型都以aiTextureType_为前缀。
  • 我们使用一个叫做loadMaterialTextures的工具函数来从材质中获取纹理。
  • 这个函数将会返回一个Texture结构体的vector,我们将在模型的textures vector的尾部之后存储它。

回顾:前面的Texture定义

![image-20230405175939568](Learn OpenGL.assets/image-20230405175939568.png)

和目前的Texture定义有些许差别(加了一个path)

loadMaterialTextures函数遍历了给定纹理类型的所有纹理位置,获取了纹理的文件位置,并加载和生成了纹理,将信息储存在了一个Texture结构体中。

它看起来会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory); //directory是Model类的成员变量
texture.type = typeName;
texture.path = str;
textures.push_back(texture);
}
return textures;
}

我们首先通过GetTextureCount函数检查储存在材质中纹理的数量,这个函数需要一个纹理类型。

我们会使用GetTexture获取==每个纹理的文件位置==,它会将结果储存在一个aiString中。

我们接下来使用另外一个叫做TextureFromFile的工具函数,它将会(用stb_image.h)加载一个纹理并返回该纹理的ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
unsigned int TextureFromFile(const char* path, const std::string& directory, bool gamma)
{
std::string filename = std::string(path);
filename = directory + '\\' + filename;

unsigned int textureID;
glGenTextures(1, &textureID);

int width, height, nrComponents;
unsigned char* data = stbi_load(filename.c_str(), &width, &height, &nrComponents, 0);
if (data)
{
GLenum format;
if (nrComponents == 1)
format = GL_RED;
else if (nrComponents == 3)
format = GL_RGB;
else if (nrComponents == 4)
format = GL_RGBA;

glBindTexture(GL_TEXTURE_2D, textureID);
glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

stbi_image_free(data);
}
else
{
std::cout << "Texture failed to load at path: " << path << std::endl;
stbi_image_free(data);
}

return textureID;
}
image

这就是使用Assimp导入模型的全部了。

重大优化

这还没有完全结束,因为我们还想做出一个重大的(但不是完全必须的)优化。大多数场景都会在多个网格中重用部分纹理。

  • 还是想想一个房子,它的墙壁有着花岗岩的纹理。这个纹理也可以被应用到地板、天花板、楼梯、桌子,甚至是附近的一口井上。

加载纹理是一个开销不小的操作,在我们当前的实现中,即便同样的纹理已经被加载过很多遍了,对每个网格仍会加载并生成一个新的纹理。这很快就会变成模型加载实现的性能瓶颈。

所以我们会对模型的代码进行调整,将所有加载过的纹理全局储存,每当我们想加载一个纹理的时候,首先去检查它有没有被加载过。

如果有的话,我们会直接使用那个纹理,并跳过整个加载流程,来为我们省下很多处理能力。为了能够比较纹理,我们还需要储存它们的路径:

1
2
3
4
5
struct Texture {
unsigned int id;
string type;
aiString path; // 我们储存纹理的路径用于与其它纹理进行比较
};

接下来我们将所有加载过的纹理储存在另一个vector中,在模型类的顶部声明为一个私有变量:

1
vector<Texture> textures_loaded;

之后,在loadMaterialTextures函数中,我们希望将纹理的路径与储存在textures_loaded这个vector中的所有纹理进行比较,看看当前纹理的路径是否与其中的一个相同。

如果是的话,则跳过纹理加载/生成的部分,直接使用定位到的纹理结构体为网格的纹理。更新后的函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
{
vector<Texture> textures;
for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
{
aiString str;
mat->GetTexture(type, i, &str);
bool skip = false;
for(unsigned int j = 0; j < textures_loaded.size(); j++)
{
if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
{
textures.push_back(textures_loaded[j]);
skip = true;
break;
}
}
if(!skip)
{ // 如果纹理还没有被加载,则加载它
Texture texture;
texture.id = TextureFromFile(str.C_Str(), directory);
texture.type = typeName;
texture.path = str.C_Str();
textures.push_back(texture);
textures_loaded.push_back(texture); // 添加到已加载的纹理中
}
}
return textures;
}

所以现在我们不仅有了个灵活的模型加载系统,我们也获得了一个加载对象很快的优化版本。

image

和箱子模型告别

这次我们将会加载Crytek的游戏孤岛危机(Crysis)中的原版纳米装(Nanosuit)。

这个模型被输出为一个.obj文件以及一个.mtl文件,.mtl文件包含了模型的漫反射、镜面光和法线贴图(这个会在后面学习到)

你可以在这里下载到(稍微修改之后的)模型,注意所有的纹理和模型文件应该位于同一个目录下,以供加载纹理。

image

现在在代码中,声明一个Model对象,将模型的文件位置传入。接下来模型应该会自动加载并(如果没有错误的话)在渲染循环中使用它的Draw函数来绘制物体,这样就可以了。

不再需要缓冲分配、属性指针和渲染指令,只需要一行代码就可以了。

根据我们之前在[光照](https://learnopengl-cn.github.io/02 Lighting/05 Light casters/)教程中学过的知识,引入两个点光源到渲染方程中,结合镜面光贴图,我们能得到很惊人的效果。

image

模型加载大致流程

image

Ch4.高级OpenGL

4.1 深度测试

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/01%20Depth%20testing/

在[坐标系统](https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/)小节中,我们渲染了一个3D箱子,并且运用了深度缓冲(Depth Buffer)来防止被阻挡的面渲染到其它面的前面。

在这一节中,我们将会更加深入地讨论这些储存在深度缓冲(或z缓冲(z-buffer))中的深度值(Depth Value),以及它们是如何确定一个片段是处于其它片段后方的。

深度缓冲就像颜色缓冲(Color Buffer)(储存所有的片段颜色:视觉输出)一样,在每个片段中储存了信息,并且(通常)和颜色缓冲有着一样的宽度和高度。

  • 深度缓冲是由窗口系统自动创建的,它会以16、24或32位float的形式储存它的深度值。在大部分的系统中,深度缓冲的精度都是24位的。
  • 当深度测试(Depth Testing)被启用的时候,OpenGL会将一个片段的深度值与深度缓冲的内容进行对比。
  • OpenGL会执行一个深度测试,如果这个测试通过了的话,深度缓冲将会更新为新的深度值。如果深度测试失败了,片段将会被丢弃。

深度缓冲是在片段着色器运行之后(以及模板测试(Stencil Testing)运行之后,我们将在[下一节](https://learnopengl-cn.github.io/04 Advanced OpenGL/02 Stencil testing/)中讨论)在屏幕空间中运行的。

==屏幕空间坐标==与通过OpenGL的glViewport所定义的视口密切相关,并且可以直接使用GLSL内建变量==gl_FragCoord==从片段着色器中直接访问。

  • gl_FragCoord的x和y分量代表了片段的屏幕空间坐标(其中(0, 0)位于左下角)。
  • gl_FragCoord中也包含了一个z分量,它包含了片段真正的深度值。z值就是需要与深度缓冲内容所对比的那个值。
image

渲染杂谈:early-z、z-culling、hi-z、z-perpass到底是什么?

知乎URL:https://zhuanlan.zhihu.com/p/389396050

  • 一旦进行了手动写入深度值、开启alpha test或者丢弃像素等操作,那么gpu就会关闭early-z直到下次clear z-buffer后才会重新开启(不过现在的gpu也在逐渐优化,使其更智能开关early-z)。
  • early-z的优化效果并不稳定,最理想条件下所有绘制顺序都是由近及远,那么early-z可以完全避免过度绘制。但是相反的状态下,则会起不到任何效果。

ChatGPT回答

image

深度测试默认是禁用的,所以如果要==启用深度测试==的话,我们需要用GL_DEPTH_TEST选项来启用它:

1
glEnable(GL_DEPTH_TEST);

当它启用的时候,如果一个片段通过了深度测试的话,OpenGL会在深度缓冲中储存该片段的z值;

如果没有通过深度缓冲,则会丢弃该片段。

如果你启用了深度缓冲,你还应该在每个渲染迭代之前使用GL_DEPTH_BUFFER_BIT来==清除深度缓冲==,否则你会仍在使用上一次渲染迭代中的写入的深度值:

1
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

可以想象,在某些情况下你会需要对所有片段都执行深度测试并丢弃相应的片段,但希望更新深度缓冲。(半透明物体)

基本上来说,你在使用一个只读的(Read-only)深度缓冲。OpenGL允许我们==禁用深度缓冲的写入==,只需要设置它的深度掩码(Depth Mask)设置为GL_FALSE就可以了:

1
glDepthMask(GL_FALSE);

注意这只在深度测试被启用的时候才有效果。

深度测试函数

OpenGL允许我们修改深度测试中使用的比较运算符。这允许我们来控制OpenGL什么时候该通过或丢弃一个片段,什么时候去更新深度缓冲。

我们可以调用glDepthFunc函数来设置比较运算符(或者说深度函数(Depth Function)):

1
glDepthFunc(GL_LESS); //深度值小的保留

这个函数接受下面表格中的比较运算符:

函数 描述
GL_ALWAYS 永远通过深度测试
GL_NEVER 永远不通过深度测试
GL_LESS 在片段深度值小于缓冲的深度值时通过测试
GL_EQUAL 在片段深度值等于缓冲区的深度值时通过测试
GL_LEQUAL 在片段深度值小于等于缓冲区的深度值时通过测试
GL_GREATER 在片段深度值大于缓冲区的深度值时通过测试
GL_NOTEQUAL 在片段深度值不等于缓冲区的深度值时通过测试
GL_GEQUAL 在片段深度值大于等于缓冲区的深度值时通过测试

默认情况下使用的深度函数是GL_LESS,它将会丢弃深度值大于等于当前深度缓冲值的所有片段。

深度值精度

深度缓冲包含了一个介于0.0和1.0之间的深度值,它将会与观察者视角所看见的场景中所有物体的z值进行比较。

观察空间的z值可能是投影平截头体的近平面(Near)和远平面(Far)之间的任何值。

我们需要一种方式来将这些观察空间的z值变换到[0, 1]范围之间,其中的一种方式就是将它们线性变换到[0, 1]范围之间。

下面这个(线性)方程将z值变换到了0.0到1.0之间的深度值:

image

这里的nearnear和farfar值是我们之前提供给投影矩阵设置可视平截头体的(见[坐标系统](https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/))那个 nearfar 值。

这个方程需要平截头体中的一个z值,并将它变换到了[0, 1]的范围中。z值和对应的深度值之间的关系可以在下图中看到:

image image
然而,在实践中是几乎永远不会使用这样的线性深度缓冲(Linear Depth Buffer)的。

要想有正确的投影性质,需要使用一个非线性的深度方程,它是与 1/z 成正比的。

它做的就是在z值很小的时候提供非常高的精度,而在z值很远的时候提供更少的精度。

花时间想想这个:我们真的需要对1000单位远的深度值和只有1单位远的充满细节的物体使用相同的精度吗?

  • 线性方程并不会考虑这一点。

由于非线性方程与 1/z 成正比,在1.0和2.0之间的z值将会变换至1.0到0.5之间的深度值,这就是一个float提供给我们的一半精度了,这在z值很小的情况下提供了非常大的精度。

在50.0和100.0之间的z值将会只占2%的float精度,这正是我们所需要的。这样的一个考虑了远近距离的方程是这样的:

image

如果你不知道这个方程是怎么回事也不用担心。重要的是要==记住深度缓冲中的值在屏幕空间中不是线性的==(在透视矩阵应用之前==在观察空间中是线性的==)。

深度缓冲中0.5的值并不代表着物体的z值是位于平截头体的中间了,这个顶点的z值实际上非常接近近平面!你可以在下图中看到z值和最终的深度缓冲值之间的非线性关系:

image

可以看到,深度值很大一部分是由很小的z值所决定的,这给了近处的物体很大的深度精度。

这个(从观察者的视角)变换z值的方程是嵌入在投影矩阵中的,所以当我们想将一个顶点坐标从观察空间至裁剪空间的时候这个非线性方程就被应用了。

如果你想深度了解投影矩阵究竟做了什么,我建议阅读这篇文章

深度缓冲的可视化

我们知道片段着色器中,内建gl_FragCoord向量的z值包含了那个特定片段的深度值。如果我们将这个深度值输出为颜色,我们可以显示场景中所有片段的深度值。我们可以根据片段的深度值返回一个颜色向量来完成这一工作:

1
2
3
4
void main()
{
FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}

如果你再次运行程序的话,你可能会注意到所有东西都是白色的,看起来就想我们所有的深度值都是最大的1.0。所以为什么没有靠近0.0(即变暗)的深度值呢?

你可能还记得在上一部分中说到,屏幕空间中的深度值是非线性的,即它在z值很小的时候有很高的精度,而z值很大的时候有较低的精度。片段的深度值会随着距离迅速增加,所以几乎所有的顶点的深度值都是接近于1.0的。如果我们小心地靠近物体,你可能会最终注意到颜色会渐渐变暗,显示它们的z值在逐渐变小:

image

这很清楚地展示了深度值的非线性性质。近处的物体比起远处的物体对深度值有着更大的影响。只需要移动几厘米就能让颜色从暗完全变白。


然而,我们也可以让片段非线性的深度值变换为==线性==的。要实现这个,我们需要仅仅反转深度值的投影变换。

这也就意味着我们需要首先将深度值从[0, 1]范围重新变换到[-1, 1]范围的标准化设备坐标(裁剪空间)。

接下来我们需要像投影矩阵那样反转这个非线性方程(方程2),并将这个反转的方程应用到最终的深度值上。

首先我们将深度值变换为NDC,不是非常困难:

1
float z = depth * 2.0 - 1.0;

接下来使用获取到的z值,应用逆变换来获取线性的深度值:

1
float linearDepth = (2.0 * near * far) / (far + near - z * (far - near));

这个方程是用投影矩阵推导得出的,它使用了方程2来非线性化深度值,返回一个near与far之间的深度值。这篇注重数学的文章为感兴趣的读者详细解释了投影矩阵,它也展示了这些方程是怎么来的。

将屏幕空间中非线性的深度值变换至线性深度值的完整片段着色器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
out vec4 FragColor;

float near = 0.1;
float far = 100.0;

float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // back to NDC
return (2.0 * near * far) / (far + near - z * (far - near));
}

void main()
{
float depth = LinearizeDepth(gl_FragCoord.z) / far; // 为了演示除以 far
FragColor = vec4(vec3(depth), 1.0);
}

由于线性化的深度值处于near与far之间,它的大部分值都会大于1.0并显示为完全的白色。

通过在main函数中将线性深度值除以far,我们近似地将线性深度值转化到[0, 1]的范围之间。

这样子我们就能逐渐看到一个片段越接近投影平截头体的远平面,它就会变得越亮,更适用于展示目的。

如果我们现在运行程序,我们就能看见深度值随着距离增大是线性的了。尝试在场景中移动,看看深度值是怎样以线性变化的。

image

颜色大部分都是黑色,因为深度值的范围是0.1的平面到100的平面,它离我们还是非常远的。

结果就是,我们相对靠近近平面,所以会得到更低的(更暗的)深度值。

深度冲突

一个很常见的视觉错误会在两个平面或者三角形非常紧密地平行排列在一起时会发生,深度缓冲没有足够的精度来决定两个形状哪个在前面。

结果就是这两个形状不断地在切换前后顺序,这会导致很奇怪的花纹。

这个现象叫做==深度冲突==(Z-fighting),因为它看起来像是这两个形状在争夺(Fight)谁该处于顶端。

深度冲突是深度缓冲的一个常见问题,当物体在远处时效果会更明显(因为深度缓冲在z值比较大的时候有着更小的精度)。

深度冲突不能够被完全避免,但一般会有一些技巧有助于在你的场景中减轻或者完全避免深度冲突、

防止深度冲突

第一个也是最重要的技巧是永远不要把多个物体摆得太靠近,以至于它们的一些三角形会重叠

第二个技巧是尽可能将近平面设置远一些

  • 在前面我们提到了精度在靠近平面时是非常高的,所以如果我们将平面远离观察者,我们将会对整个平截头体有着更大的精度。
  • 然而,将近平面设置太远将会导致近处的物体被裁剪掉,所以这通常需要实验和微调来决定最适合你的场景的平面距离。

另外一个很好的技巧是牺牲一些性能,使用更高精度的深度缓冲

  • 大部分深度缓冲的精度都是24位的,但现在大部分的显卡都支持32位的深度缓冲,这将会极大地提高精度。

我们上面讨论的三个技术是最普遍也是很容易实现的抗深度冲突技术了。还有一些更复杂的技术,但它们依然不能完全消除深度冲突。深度冲突是一个常见的问题,但如果你组合使用了上面列举出来的技术,你可能不会再需要处理深度冲突了。

4.2 模板测试

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/02%20Stencil%20testing/

当片段着色器处理完一个片段之后,==模板测试==(Stencil Test)会开始执行,和深度测试一样,它也可能会丢弃片段。

接下来,被保留的片段会进入深度测试,它可能会丢弃更多的片段。模板测试是根据又一个缓冲来进行的,它叫做==模板缓冲==(Stencil Buffer)

一个模板缓冲中,(通常)每个模板值(Stencil Value)是8位的。所以每个像素/片段一共能有256种不同的模板值。

我们可以将这些模板值设置为我们想要的值,然后当某一个片段有某一个模板值的时候,我们就可以选择丢弃或是保留这个片段了。

image image

模板缓冲首先会被清除为0,之后在模板缓冲中使用1填充了一个空心矩形。场景中的片段将会只在片段的模板值为1的时候会被渲染(其它的都被丢弃了)。


模板缓冲操作允许我们在渲染片段时将模板缓冲设定为一个特定的值。

通过在渲染时修改模板缓冲的内容,我们写入了模板缓冲。

在同一个(或者接下来的)渲染迭代中,我们可以读取这些值,来决定丢弃还是保留某个片段。

使用模板缓冲的时候你可以尽情发挥,但大体的步骤如下:

  • 启用模板测试
  • 启用模板缓冲的写入。
  • 渲染物体,更新模板缓冲的内容。
  • 禁用模板缓冲的写入。
  • 渲染(其它)物体,这次根据模板缓冲的内容丢弃特定的片段。

所以,通过使用模板缓冲,我们可以根据场景中已绘制的其它物体的片段,来决定是否丢弃特定的片段。


你可以==启用==`GL_STENCIL_TEST`来启用模板测试。在这一行代码之后,所有的渲染调用都会以某种方式影响着模板缓冲。
1
glEnable(GL_STENCIL_TEST);

注意,和颜色和深度缓冲一样,你也需要在每次迭代之前==清除模板缓冲==。
1
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

和深度测试的glDepthMask函数一样,模板缓冲也有一个类似的函数。

glStencilMask允许我们设置一个位掩码(Bitmask),它会与将要==写入缓冲==的模板值进行==与==(AND)运算。

默认情况下设置的位掩码所有位都为1,不影响输出,但如果我们将它设置为0x00,写入缓冲的所有模板值最后都会变成0.这与深度测试中的glDepthMask(GL_FALSE)是等价的。

1
2
glStencilMask(0xFF); // 每一位写入模板缓冲时都保持原样
glStencilMask(0x00); // 每一位在写入模板缓冲时都会变成0(禁用写入)

大部分情况下你都只会使用0x00或者0xFF作为模板掩码(Stencil Mask),但是知道有选项可以设置自定义的位掩码总是好的。

模板函数

和深度测试一样,我们对模板缓冲应该通过还是失败,以及它应该如何影响模板缓冲,也是有一定控制的。

一共有两个函数能够用来配置模板测试:==glStencilFunc==和==glStencilOp==。

glStencilFunc(GLenum func, GLint ref, GLuint mask)一共包含三个参数:

  • 功能:满足参数设定条件的片段会被渲染,否则被丢弃
  • func:设置模板测试函数(Stencil Test Function)。这个测试函数将会应用到已储存的模板值上和glStencilFunc函数的ref值上。可用的选项有:GL_NEVERGL_LESSGL_LEQUALGL_GREATERGL_GEQUALGL_EQUALGL_NOTEQUALGL_ALWAYS。它们的语义和深度缓冲的函数类似。
  • ref:设置了模板测试的参考值(Reference Value)。模板缓冲的内容将会与这个值进行比较。
  • mask:设置一个掩码,它将会与参考值和储存的模板值在测试比较它们==之前==进行与(AND)运算。初始情况下所有位都为1。

在一开始的那个简单的模板例子中,函数被设置为:

1
glStencilFunc(GL_EQUAL, 1, 0xFF)

这会告诉OpenGL,只要一个片段的模板值等于(GL_EQUAL)参考值1,片段将会通过测试并被绘制,否则会被丢弃。


但是glStencilFunc仅仅描述了OpenGL应该对模板缓冲内容做什么,而不是我们应该如何==更新缓冲==。这就需要glStencilOp这个函数了。

glStencilOp(GLenum sfail, GLenum dpfail, GLenum dppass)一共包含三个选项,我们能够设定每个选项应该采取的行为:

  • sfail:模板测试失败时采取的行为。
  • dpfail:模板测试通过,但深度测试失败时采取的行为。
  • dppass:模板测试和深度测试都通过时采取的行为。

每个选项都可以选用以下的其中一种行为:

行为 描述
GL_KEEP 保持当前储存的模板值
GL_ZERO 将模板值设置为0
GL_REPLACE 将模板值设置为glStencilFunc函数设置的ref
GL_INCR 如果模板值小于最大值则将模板值加1
GL_INCR_WRAP 与GL_INCR一样,但如果模板值超过了最大值则归零
GL_DECR 如果模板值大于最小值则将模板值减1
GL_DECR_WRAP 与GL_DECR一样,但如果模板值小于0则将其设置为最大值
GL_INVERT 按位翻转当前的模板缓冲值

默认情况下glStencilOp是设置为(GL_KEEP, GL_KEEP, GL_KEEP)的,所以不论任何测试的结果是如何,模板缓冲都会保留它的值。

默认的行为不会更新模板缓冲,所以如果你想写入模板缓冲的话,你需要至少对其中一个选项设置不同的值。


所以,通过使用glStencilFunc和glStencilOp,我们可以精确地指定更新模板缓冲的时机与行为了,我们也可以指定什么时候该让模板缓冲通过,即什么时候片段需要被丢弃。

物体轮廓

仅仅看了前面的部分你还是不太可能能够完全理解模板测试的工作原理,所以我们将会展示一个使用模板测试就可以完成的有用特性,它叫做物体轮廓(Object Outlining)。

  • 物体轮廓所能做的事情正如它名字所描述的那样。我们将会为每个(或者一个)物体在它的周围创建一个很小的有色边框。

当你想要在策略游戏中选中一个单位进行操作的,想要告诉玩家选中的是哪个单位的时候,这个效果就非常有用了。

为物体创建轮廓的步骤如下:

  1. 在绘制(需要添加轮廓的)物体之前,将模板函数设置为GL_ALWAYS,每当物体的片段被渲染时,将模板缓冲更新为1。
  2. 渲染物体。
  3. 禁用模板写入以及深度测试。
  4. 将每个物体缩放一点点。
  5. 使用一个不同的片段着色器,输出一个单独的(边框)颜色。
  6. 再次绘制物体,但只在它们片段的模板值不等于1时才绘制。
  7. 再次启用模板写入和深度测试。

这个过程将每个物体的片段的模板缓冲设置为1,当我们想要绘制边框的时候,我们主要绘制放大版本的物体中模板测试通过的部分,也就是物体的边框的位置。

所以我们首先来创建一个很简单的片段着色器,它会输出一个边框颜色。我们简单地给它设置一个硬编码的颜色值,将这个着色器命名为shaderSingleColor:

1
2
3
4
void main()
{
FragColor = vec4(0.04, 0.28, 0.26, 1.0);
}

我们只想给那两个箱子加上边框,所以我们让地板不参与这个过程。

我们希望首先绘制地板,再绘制两个箱子(并写入模板缓冲),之后绘制放大的箱子(并丢弃覆盖了之前绘制的箱子片段的那些片段)。

我们首先启用模板测试,并设置测试通过或失败时的行为:

1
2
glEnable(GL_STENCIL_TEST);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);

如果其中的一个测试失败了,我们什么都不做,我们仅仅保留当前储存在模板缓冲中的值。

如果模板测试和深度测试都通过了,那么我们希望将储存的模板值设置为参考值,参考值能够通过glStencilFunc来设置,我们之后会设置为1。


我们将模板缓冲清除为0,对箱子中所有绘制的片段,将模板值更新为1:

1
2
3
4
glStencilFunc(GL_ALWAYS, 1, 0xFF); // 所有的片段都应该更新模板缓冲
glStencilMask(0xFF); // 启用模板缓冲写入
normalShader.use();
DrawTwoContainers();

通过使用GL_ALWAYS模板测试函数,我们保证了箱子的每个片段都会将模板缓冲的模板值更新为1。

因为片段永远会通过模板测试,在绘制片段的地方,模板缓冲会被更新为参考值。


现在模板缓冲在箱子被绘制的地方都更新为1了,我们将要绘制放大的箱子,但这次要禁用模板缓冲的写入:

1
2
3
4
5
glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00); // 禁止模板缓冲的写入
glDisable(GL_DEPTH_TEST);
shaderSingleColor.use();
DrawTwoScaledUpContainers();

我们将模板函数设置为GL_NOTEQUAL,它会保证我们只绘制箱子上模板值不为1的部分,即只绘制箱子在之前绘制的箱子之外的部分。

注意我们也禁用了深度测试,让放大的箱子,即边框,不会被地板所覆盖。

记得要在完成之后重新启用深度缓冲。

场景中物体轮廓的完整步骤会看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
glEnable(GL_DEPTH_TEST); //不启用模板测试??
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

glStencilMask(0x00); // 记得保证我们在绘制地板的时候不会更新模板缓冲
normalShader.use();
DrawFloor()

glStencilFunc(GL_ALWAYS, 1, 0xFF);
glStencilMask(0xFF);
DrawTwoContainers();

glStencilFunc(GL_NOTEQUAL, 1, 0xFF);
glStencilMask(0x00);
glDisable(GL_DEPTH_TEST);
shaderSingleColor.use();
DrawTwoScaledUpContainers();
glStencilMask(0xFF);
glEnable(GL_DEPTH_TEST);
image image

补充:轮廓绘制流程图

image

补充:带遮挡轮廓

技巧:每个立方体单独渲染自身和轮廓,使用两个不同的模板参考值即可

详见工程代码

image

4.3 混合

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/03%20Blending/

OpenGL中,混合(Blending)通常是实现物体透明度(Transparency)的一种技术。

透明就是说一个物体(或者其中的一部分)不是纯色(Solid Color)的,它的颜色是物体本身的颜色和它背后其它物体的颜色的不同强度结合。

一个有色玻璃窗是一个透明的物体,玻璃有它自己的颜色,但它最终的颜色还包含了玻璃之后所有物体的颜色。

这也是混合这一名字的出处,我们混合(Blend)(不同物体的)多种颜色为一种颜色。所以透明度能让我们看穿物体。

image

透明的物体可以是完全透明的(让所有的颜色穿过),或者是半透明的(它让颜色通过,同时也会显示自身的颜色)。

一个物体的透明度是通过它颜色的alpha值来决定的。Alpha颜色值是颜色向量的第四个分量,你可能已经看到过它很多遍了。

在这个教程之前我们都将这个第四个分量设置为1.0,让这个物体的透明度为0.0(不透明度为1),而当alpha值为0.0时物体将会是完全透明的。

当alpha值为0.5时,物体的颜色有50%是来自物体自身的颜色,50%来自背后物体的颜色。


我们目前一直使用的纹理有三个颜色分量:红、绿、蓝。但一些材质会有一个内嵌的alpha通道,对每个纹素(Texel)都包含了一个alpha值。这个alpha值精确地告诉我们纹理各个部分的透明度。

透明度测试(丢弃片段)

有些图片并不需要半透明,只需要根据纹理颜色值,显示一部分,或者不显示一部分,没有中间情况。

  • 比如说草,如果想不太费劲地创建草这种东西,你需要将一个草的纹理贴在一个2D四边形(Quad)上,然后将这个四边形放到场景中。
  • 然而,草的形状和2D四边形的形状并不完全相同,所以你只想显示草纹理的某些部分,而忽略剩下的部分。

你可以看到,只要不是草的部分,这个图片显示的都是网站的背景颜色而不是它本身的颜色。

image

所以当添加像草这样的植被到场景中时,我们不希望看到草的方形图像,而是只显示草的部分,并能看透图像其余的部分。

我们想要丢弃(Discard)显示纹理中透明部分的片段,不将这些片段存储到颜色缓冲中。在此之前,我们还要学习如何加载一个透明的纹理。


要想加载有alpha值的纹理,我们并不需要改很多东西,`stb_image`在纹理有alpha通道的时候会自动加载,但我们仍要在纹理生成过程中告诉OpenGL,我们的纹理现在使用alpha通道了:
1
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, data);

同样,保证你在片段着色器中获取了纹理的全部4个颜色分量,而不仅仅是RGB分量:

1
2
3
4
5
void main()
{
// FragColor = vec4(vec3(texture(texture1, TexCoords)), 1.0);
FragColor = texture(texture1, TexCoords);
}

既然我们已经知道该如何加载透明的纹理了,是时候将它带入实战了,我们将会在深度测试小节的场景中加入几棵草。

我们会创建一个vector,向里面添加几个glm::vec3变量来代表草的位置:

1
2
3
4
5
6
vector<glm::vec3> vegetation;
vegetation.push_back(glm::vec3(-1.5f, 0.0f, -0.48f));
vegetation.push_back(glm::vec3( 1.5f, 0.0f, 0.51f));
vegetation.push_back(glm::vec3( 0.0f, 0.0f, 0.7f));
vegetation.push_back(glm::vec3(-0.3f, 0.0f, -2.3f));
vegetation.push_back(glm::vec3( 0.5f, 0.0f, -0.6f));

每个草都被渲染到了一个四边形上,贴上草的纹理。

这并不能完美地表示3D的草,但这比加载复杂的模型要快多了。

使用一些小技巧,比如在同一个位置加入一些旋转后的草四边形,你仍然能获得比较好的结果的。

因为草的纹理是添加到四边形对象上的,我们还需要创建另外一个VAO,填充VBO,设置正确的顶点属性指针。接下来,在绘制完地板和两个立方体后,我们将会绘制草:

1
2
3
4
5
6
7
8
9
glBindVertexArray(vegetationVAO);
glBindTexture(GL_TEXTURE_2D, grassTexture);
for(unsigned int i = 0; i < vegetation.size(); i++)
{
model = glm::mat4(1.0f);
model = glm::translate(model, vegetation[i]);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}

运行程序你将看到:

image

出现这种情况是因为OpenGL默认是不知道怎么处理alpha值的,更不知道什么时候应该丢弃片段。

幸运的是,有了着色器,这还是非常容易的。GLSL给了我们discard命令,一旦被调用,它就会保证片段不会被进一步处理,所以就不会进入颜色缓冲。

有了这个指令,我们就能够在片段着色器中检测一个片段的alpha值是否低于某个阈值,如果是的话,则丢弃这个片段,就好像它不存在一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

void main()
{
vec4 texColor = texture(texture1, TexCoords);
if(texColor.a < 0.1)
discard;
FragColor = texColor;
}

这里,我们检测被采样的纹理颜色的alpha值是否低于0.1的阈值,如果是的话,则丢弃这个片段。片段着色器保证了它只会渲染不是(几乎)完全透明的片段。现在它看起来就正常了:

image image

透明度混合

虽然直接丢弃片段很好,但它不能让我们渲染半透明的图像。我们要么渲染一个片段,要么完全丢弃它。

要想渲染有多个透明度级别的图像,我们需要启用混合(Blending)。

和OpenGL大多数的功能一样,我们可以启用GL_BLEND来启用混合:

1
glEnable(GL_BLEND);

启用了混合之后,我们需要告诉OpenGL它该如何混合。

OpenGL中的混合是通过下面这个方程来实现的:

image

片段着色器运行完成后,并且==所有的测试都通过之后==,这个混合方程(Blend Equation)才会应用到片段颜色输出与当前颜色缓冲中的值(当前片段之前储存的之前片段的颜色)上。

源颜色和目标颜色将会由OpenGL自动设定,但源因子和目标因子的值可以由我们来决定。我们先来看一个简单的例子:

image

我们有两个方形,我们希望将这个半透明的绿色方形绘制在红色方形之上。红色的方形将会是目标颜色(所以它应该先在颜色缓冲中),我们将要在这个红色方形之上绘制这个绿色方形。

问题来了:我们将因子值设置为什么?

我们至少想让绿色方形乘以它的alpha值,所以我们想要将Fsrc设置为源颜色向量的alpha值,也就是0.6。

接下来就应该清楚了,目标方形的贡献应该为剩下的alpha值。如果绿色方形对最终颜色贡献了60%,那么红色方块应该对最终颜色贡献了40%,即1.0 - 0.6

所以我们将FdestinationFdestination设置为1减去源颜色向量的alpha值。这个方程变成了:

image

结果就是重叠方形的片段包含了一个60%绿色,40%红色的一种脏兮兮的颜色:

image

最终的颜色将会被储存到颜色缓冲中,替代之前的颜色。


这样子很不错,但我们该如何让OpenGL使用这样的因子呢?正好有一个专门的函数,叫做==glBlendFunc==。

glBlendFunc(GLenum sfactor, GLenum dfactor)函数接受两个参数,来设置源和目标因子。

OpenGL为我们定义了很多个选项,我们将在下面列出大部分最常用的选项。

  • 注意常数颜色向量C¯constant可以通过glBlendColor函数来另外设置。
image

为了获得之前两个方形的混合结果,我们需要使用源颜色向量的alpha作为源因子,使用1−alpha作为目标因子。

这将会产生以下的glBlendFunc:

1
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

也可以使用glBlendFuncSeparate为RGBalpha通道分别设置不同的选项:

1
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ZERO);

这个函数和我们之前设置的那样设置了RGB分量,但这样只能让最终的alpha分量被源颜色向量的alpha值所影响到。


OpenGL甚至给了我们更多的灵活性,允许我们改变方程中源和目标部分的运算符。

当前源和目标是相加的,但如果愿意的话,我们也可以让它们相减。

glBlendEquation(GLenum mode)允许我们设置运算符,它提供了三个选项:

image

通常我们都可以省略调用glBlendEquation,因为GL_FUNC_ADD对大部分的操作来说都是我们希望的混合方程,但如果你真的想打破主流,其它的方程也可能符合你的要求。

渲染半透明纹理

既然我们已经知道OpenGL是如何处理混合的了,是时候将我们的知识运用到实战中了,我们将会在场景中添加几个半透明的窗户。

我们将使用本节开始的那个场景,但是这次不再是渲染草的纹理了,我们现在将使用本节开始时的那个透明的窗户纹理。


首先,在初始化时我们启用混合,并设定相应的混合函数:

1
2
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

由于启用了混合,我们就不需要丢弃片段了,所以我们把片段着色器还原:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D texture1;

void main()
{
FragColor = texture(texture1, TexCoords);
}

现在(每当OpenGL渲染了一个片段时)它都会将当前片段的颜色和当前颜色缓冲中的片段颜色根据alpha值来进行混合。由于窗户纹理的玻璃部分是半透明的,我们应该能通窗户中看到背后的场景了。

image

如果你仔细看的话,你可能会注意到有些不对劲。最前面窗户的透明部分遮蔽了背后的窗户?这为什么会发生呢?

发生这一现象的原因是,深度测试和混合一起使用的话会产生一些麻烦

  • 当写入深度缓冲时,深度缓冲不会检查片段是否是透明的,所以透明的部分会和其它值一样写入到深度缓冲中。
  • 结果就是窗户的整个四边形不论透明度都会进行深度测试。即使透明的部分应该显示背后的窗户,深度测试仍然丢弃了它们。

所以我们不能随意地决定如何渲染窗户,让深度缓冲解决所有的问题了。这也是混合变得有些麻烦的部分。

要想保证窗户中能够显示它们背后的窗户,我们需要首先绘制背后的这部分窗户。这也就是说在绘制的时候,我们必须先手动将窗户按照==最远到最近来排序==,再==按照顺序渲染==。

image

不要打乱顺序

要想让混合在多个物体上工作,我们需要最先绘制最远的物体,最后绘制最近的物体。

普通不需要混合的物体仍然可以使用深度缓冲正常绘制,所以它们不需要排序。但我们仍要保证它们在绘制(排序的)透明物体之前已经绘制完毕了。

当绘制一个有不透明和透明物体的场景的时候,大体的原则如下:

  1. 先绘制所有不透明的物体。
  2. 对所有透明的物体排序。
  3. 按顺序绘制所有透明的物体。

排序透明物体的一种方法是,从观察者视角获取物体的距离。

  • 这可以通过计算摄像机位置向量和物体的位置向量之间的距离所获得。

接下来我们把距离和它对应的位置向量存储到一个STL库的map数据结构中。

  • map会自动根据键值(Key)对它的值排序,所以只要我们添加了所有的位置,并以它的距离作为键,它们就会自动根据距离值排序了。
1
2
3
4
5
6
std::map<float, glm::vec3> sorted;
for (unsigned int i = 0; i < windows.size(); i++)
{
float distance = glm::length(camera.Position - windows[i]);
sorted[distance] = windows[i];
}

结果就是一个排序后的容器对象,它根据distance键值从低到高储存了每个窗户的位置。

之后,这次在渲染的时候,我们将以逆序(从远到近)从map中获取值,之后以正确的顺序绘制对应的窗户:

1
2
3
4
5
6
7
for(std::map<float,glm::vec3>::reverse_iterator it = sorted.rbegin(); it != sorted.rend(); ++it) 
{
model = glm::mat4();
model = glm::translate(model, it->second);
shader.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}

我们使用了map的一个反向迭代器(Reverse Iterator),反向遍历其中的条目,并将每个窗户四边形位移到对应的窗户位置上。

这是排序透明物体的一个比较简单的实现,它能够修复之前的问题,现在场景看起来是这样的:

image

虽然按照距离排序物体这种方法对我们这个场景能够正常工作,但它并没有考虑旋转、缩放或者其它的变换,奇怪形状的物体需要一个不同的计量,而不是仅仅一个位置向量。

说明:渲染透明物体时最好关闭深度写入,上面没有关闭结果仍正确是因为半透明物体之间无重叠。当半透明物体存在重叠,那么由于排序是基于对象的,而深度测试是基于像素的。那么后面渲染的物体如果写入深度,可能会遮挡住前面的物体,导致前面物体部分片段被剔除,进而导致结果错误。


在场景中排序物体是一个很困难的技术,很大程度上由你场景的类型所决定,更别说它额外需要消耗的处理能力了。

完整渲染一个包含不透明和透明物体的场景并不是那么容易。

更高级的技术还有==次序无关透明度==(Order Independent Transparency, OIT),但这超出本教程的范围了。

现在,你还是必须要普通地混合你的物体,但如果你很小心,并且知道目前方法的限制的话,你仍然能够获得一个比较不错的混合实现。

4.4 面剔除

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/04%20Face%20culling/

尝试在脑子中想象一个3D立方体,数数你从任意方向最多能同时看到几个面。如果你的想象力不是过于丰富了,你应该能得出最大的面数是3。

你可以从任意位置和任意方向看向这个球体,但你永远不能看到3个以上的面。所以我们为什么要浪费时间绘制我们不能看见的那3个面呢?

如果我们能够以某种方式丢弃这几个看不见的面,我们能省下超过50%的片段着色器执行数!

image

这是一个很好的主意,但我们仍有一个问题需要解决:我们如何知道一个物体的某一个面不能从观察者视角看到呢?

如果我们想象任何一个闭合形状,它的每一个面都有两侧,每一侧要么面向用户,要么背对用户。如果我们能够只绘制面向观察者的面呢?

这正是==面剔除==(Face Culling)所做的。

OpenGL能够检查所有面向(Front Facing)观察者的面,并渲染它们,而丢弃那些背向(Back Facing)的面,节省我们很多的片段着色器调用(它们的开销很大!)。

但我们仍要告诉OpenGL哪些面是正向面(Front Face),哪些面是背向面(Back Face)。OpenGL使用了一个很聪明的技巧,分析顶点数据的环绕顺序(Winding Order)。

环绕顺序

当我们定义一组三角形顶点时,我们会以特定的环绕顺序来定义它们,可能是顺时针(Clockwise)的,也可能是逆时针(Counter-clockwise)的。

每个三角形由3个顶点所组成,我们会从三角形中间来看,为这3个顶点设定一个环绕顺序。

image

可以看到,我们首先定义了顶点1,之后我们可以选择定义顶点2或者顶点3,这个选择将定义了这个三角形的环绕顺序。下面的代码展示了这点:

1
2
3
4
5
6
7
8
9
10
float vertices[] = {
// 顺时针
vertices[0], // 顶点1
vertices[1], // 顶点2
vertices[2], // 顶点3
// 逆时针
vertices[0], // 顶点1
vertices[2], // 顶点3
vertices[1] // 顶点2
};

每组组成三角形图元的三个顶点就包含了一个环绕顺序。


OpenGL在渲染图元的时候将使用这个信息来决定一个三角形是一个正向三角形还是背向三角形。

默认情况下,==逆时针==顶点所定义的三角形将会被处理为==正向三角形==。

当你定义顶点顺序的时候,你应该想象对应的三角形是面向你的,所以你定义的三角形从正面看去应该是逆时针的。

  • 这样定义顶点很棒的一点是,实际的环绕顺序是在光栅化阶段进行的,也就是顶点着色器运行之后。

这些顶点就是从观察者视角所见的了。

  • 观察者所面向的所有三角形顶点就是我们所指定的正确环绕顺序了,而立方体另一面的三角形顶点则是以相反的环绕顺序所渲染的。
  • 这样的结果就是,我们所面向的三角形将会是正向三角形,而背面的三角形则是背向三角形。下面这张图显示了这个效果:
image

在顶点数据中,我们将两个三角形都以逆时针顺序定义(正面的三角形是1、2、3,背面的三角形也是1、2、3(如果我们从正面看这个三角形的话))。

然而,如果从观察者当前视角使用1、2、3的顺序来绘制的话,从观察者的方向来看,背面的三角形将会是以顺时针顺序渲染的。

虽然背面的三角形是以逆时针定义的,它现在是以顺时针顺序渲染的了。

这正是我们想要==剔除==(Cull,丢弃)的不可见面了!

面剔除实现

在本节的开头我们就说过,OpenGL能够丢弃那些渲染为背向三角形的三角形图元。

既然已经知道如何设置顶点的环绕顺序了,我们就可以使用OpenGL的==面剔除选项==了,它默认是==禁用==状态的。

在之前教程中使用的立方体顶点数据并不是按照逆时针环绕顺序定义的,所以我更新了顶点数据,来反映逆时针的环绕顺序,

要想启用面剔除,我们只需要启用OpenGL的GL_CULL_FACE选项:

1
glEnable(GL_CULL_FACE);

从这一句代码之后,所有背向面都将被丢弃(尝试飞进立方体内部,看看所有的内面是不是都被丢弃了)。

目前我们在渲染片段的时候能够节省50%以上的性能,但注意这只对像立方体这样的封闭形状有效。

当我们想要绘制[上一节](https://learnopengl-cn.github.io/04 Advanced OpenGL/03 Blending/)中的草时,我们必须要再次禁用面剔除,因为它们的正向面和背向面都应该是可见的。


OpenGL允许我们改变需要剔除的面的类型。如果我们只想剔除正向面而不是背向面会怎么样?我们可以调用glCullFace来定义这一行为:

1
glCullFace(GL_FRONT);

glCullFace函数有三个可用的选项:

  • GL_BACK:只剔除背向面。
  • GL_FRONT:只剔除正向面。
  • GL_FRONT_AND_BACK:剔除正向面和背向面。

glCullFace的初始值是GL_BACK。

除了需要剔除的面之外,我们也可以通过调用glFrontFace,告诉OpenGL我们希望将顺时针的面(而不是逆时针的面)定义为正向面:

1
glFrontFace(GL_CW);

默认值是GL_CCW,它代表的是逆时针的环绕顺序,另一个选项是GL_CW,它(显然)代表的是顺时针顺序。

我们可以来做一个实验,告诉OpenGL现在顺时针顺序代表的是正向面:

1
2
3
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
glFrontFace(GL_CW);

这样的结果是只有背向面被渲染了:

image

注意你可以仍使用默认的逆时针环绕顺序,但剔除正向面,来达到相同的效果:

1
2
glEnable(GL_CULL_FACE);
glCullFace(GL_FRONT);

可以看到,面剔除是一个提高OpenGL程序性能的很棒的工具。但你需要记住哪些物体能够从面剔除中获益,而哪些物体不应该被剔除。

4.5 帧缓冲

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/05%20Framebuffers/

帧缓冲 颜色类型(A商品) 深度类型(B商品) 模板类型(C商品)
纹理附件(顺丰普快)
渲染缓冲对象(顺丰特快)

到目前为止,我们已经使用了很多屏幕缓冲了:用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。

这些缓冲==结合==起来叫做==帧缓冲==(Framebuffer),它被储存在内存中。

OpenGL允许我们定义我们自己的帧缓冲,也就是说我们能够定义我们自己的颜色缓冲,甚至是深度缓冲和模板缓冲。

我们目前所做的所有操作都是在默认帧缓冲的渲染缓冲上进行的。

  • 默认的帧缓冲是在你创建窗口的时候生成和配置的(GLFW帮我们做了这些)。

有了我们自己的帧缓冲,我们就能够有更多方式来渲染了。

你可能不能很快理解帧缓冲的应用,但渲染你的场景到不同的帧缓冲能够让我们在场景中加入类似镜子的东西,或者做出很酷的后期处理效果。

首先我们会讨论它是如何工作的,之后我们将来实现这些炫酷的后期处理效果。

创建一个帧缓冲 FBO

和OpenGL中的其它对象一样,我们会使用一个叫做glGenFramebuffers的函数来创建一个帧缓冲对象(Framebuffer Object, FBO):

1
2
unsigned int fbo;
glGenFramebuffers(1, &fbo);

这种创建和使用对象的方式我们已经见过很多次了,所以它的使用函数也和其它的对象类似。


首先我们创建一个帧缓冲对象,将它绑定为激活的(Active)帧缓冲,做一些操作,之后解绑帧缓冲。我们使用glBindFramebuffer来绑定帧缓冲。
1
glBindFramebuffer(GL_FRAMEBUFFER, fbo);

在绑定到GL_FRAMEBUFFER目标之后,所有的读取写入帧缓冲的操作将会影响当前绑定的帧缓冲。

我们也可以使用GL_READ_FRAMEBUFFER或GL_DRAW_FRAMEBUFFER,将一个帧缓冲分别绑定到读取目标或写入目标。

  • 绑定到GL_READ_FRAMEBUFFER的帧缓冲将会使用在所有像是glReadPixels的读取操作中
  • 而绑定到GL_DRAW_FRAMEBUFFER的帧缓冲将会被用作渲染、清除等写入操作的目标

大部分情况你都不需要区分它们,通常都会使用GL_FRAMEBUFFER,绑定到两个上。


#### 完整的帧缓冲条件

不幸的是,我们现在还不能使用我们的帧缓冲,因为它还不完整(Complete),一个==完整的帧缓冲==需要满足以下的条件:

  • 附加至少一个缓冲类型(颜色、深度或模板缓冲)。
  • 至少有一个==颜色附件==(Attachment)(附件是实际数据)。
  • 所有的附件都必须是完整的(有内存)。
  • 每个缓冲都应该有相同的样本数(抗锯齿有关)。

如果你不知道什么是样本,不要担心,我们将在[之后的](https://learnopengl-cn.github.io/04 Advanced OpenGL/11 Anti Aliasing/)教程中讲到。

ChatGPT解释

image

从上面的条件中可以知道,我们需要为帧缓冲创建一些附件,并将附件附加到帧缓冲上。

在完成所有的条件之后,我们可以以GL_FRAMEBUFFER为参数调用glCheckFramebufferStatus,检查帧缓冲是否完整

它将会检测当前绑定的帧缓冲,并返回规范中这些值的其中之一。

  • 如果它返回的是GL_FRAMEBUFFER_COMPLETE,帧缓冲就是完整的了。
1
2
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE)
// 执行胜利的舞蹈

之后所有的渲染操作将会渲染到当前绑定帧缓冲的附件中


由于我们的帧缓冲不是默认帧缓冲,渲染指令将不会对窗口的视觉输出有任何影响。

出于这个原因,渲染到一个不同的帧缓冲被叫做==离屏渲染==(Off-screen Rendering)。

要保证所有的渲染操作在主窗口中有视觉效果,我们需要再次激活默认帧缓冲,将它绑定到0

1
glBindFramebuffer(GL_FRAMEBUFFER, 0);

在完成所有的帧缓冲操作之后,不要忘记删除这个帧缓冲对象:

1
glDeleteFramebuffers(1, &fbo);

在完整性检查执行之前,我们需要给帧缓冲附加一个附件。附件是一个内存位置,它能够作为帧缓冲的一个缓冲,可以将它想象为一个图像

当创建一个附件的时候我们有两个选项:==纹理==或==渲染缓冲对象==(Renderbuffer Object)。

纹理附件

当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。

使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,我们之后可以在着色器中很方便地使用它。

为==帧缓冲创建一个纹理==和创建一个普通的纹理差不多:

1
2
3
4
5
6
7
8
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

主要的区别就是,我们将维度设置为了屏幕大小(尽管这不是必须的),并且我们给纹理的data参数传递了NULL

对于这个纹理,我们仅仅分配了内存而没有填充它。填充这个纹理将会在我们渲染到帧缓冲之后来进行。

同样注意我们并不关心环绕方式或多级渐远纹理,我们在大多数情况下都不会需要它们。

image

现在我们已经创建好一个纹理了,要做的最后一件事就是将它==附加到帧缓冲==上了:

1
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

==glFrameBufferTexture2D==有以下的参数:

  • target:帧缓冲的目标(绘制、读取或者两者皆有)
  • attachment:我们想要附加的附件类型。当前我们正在附加一个颜色附件。注意最后的0意味着我们可以附加多个颜色附件。我们将在之后的教程中提到。
  • textarget:你希望附加的纹理类型
  • texture:要附加的纹理本身
  • level:多级渐远纹理的级别。我们将它保留为0。

除了颜色附件之外,我们还可以附加一个深度和模板缓冲纹理到帧缓冲对象中。

  • 要==附加深度缓冲==的话,我们将附件类型设置为GL_DEPTH_ATTACHMENT。注意纹理的格式(Format)和内部格式(Internalformat)类型将变为GL_DEPTH_COMPONENT,来反映深度缓冲的储存格式。
  • 要==附加模板缓冲==的话,你要将第二个参数设置为GL_STENCIL_ATTACHMENT,并将纹理的格式设定为GL_STENCIL_INDEX。

也可以将深度缓冲和模板缓冲附加为一个单独的纹理。

  • 纹理的每32位数值将包含24位的深度信息和8位的模板信息。
  • 要将==深度和模板缓冲附加为一个纹理==的话,我们使用GL_DEPTH_STENCIL_ATTACHMENT类型,并配置纹理的格式,让它包含合并的深度和模板值。(OpenGL会在深度测试、模板测试阶段自动使用绑定到帧缓冲上的深度和模板缓冲,用户无需关心实现,只需绑定即可)

将一个深度和模板缓冲附加为一个纹理到帧缓冲的例子可以在下面找到:

1
2
3
4
5
6
glTexImage2D(
GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0,
GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);

glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);

渲染缓冲对象附件

渲染缓冲对象(Renderbuffer Object)是在纹理之后引入到OpenGL中,作为一个可用的帧缓冲附件类型的,所以在过去纹理是唯一可用的附件。

和纹理图像一样,渲染缓冲对象是一个真正的缓冲,即一系列的字节、整数、像素等。

渲染缓冲对象附加的==好处==是,它会将数据储存为OpenGL原生的渲染格式,它是为离屏渲染到帧缓冲优化过的。

渲染缓冲对象直接将所有的渲染数据储存到它的缓冲中,不会做任何针对纹理格式的转换,从而 让它变为一个更快的可写储存介质

  • 然而,渲染缓冲对象通常都是==只写的==,所以你不能读取它们(比如使用纹理访问)。(不太好理解啊!!!)
  • 当然你仍然还是能够使用glReadPixels来读取它,这会==从当前绑定的帧缓冲==,而不是附件本身中==返回特定区域的像素==。(所以相当于是全局唯一?有新的就覆盖旧的呗?)

因为它的数据已经是原生的格式了,当写入或者复制它的数据到其它缓冲中时是非常快的。所以,交换缓冲这样的操作在使用渲染缓冲对象时会非常快。

我们在每个渲染迭代最后使用的glfwSwapBuffers,也可以通过渲染缓冲对象实现:只需要写入一个渲染缓冲图像,并在最后交换到另外一个渲染缓冲就可以了。渲染缓冲对象对这种操作非常完美。

==创建一个渲染缓冲对象==的代码和帧缓冲的代码很类似:

1
2
unsigned int rbo;
glGenRenderbuffers(1, &rbo);

类似,我们需要==绑定==这个渲染缓冲对象,让之后所有的渲染缓冲操作影响当前的rbo:

1
glBindRenderbuffer(GL_RENDERBUFFER, rbo);

由于渲染缓冲对象通常都是只写的,它们会经常用于深度和模板附件,因为大部分时间我们都不需要从深度和模板缓冲中读取值,只关心深度和模板测试。

我们需要深度和模板值用于测试,但不需要对它们进行采样,所以渲染缓冲对象非常适合它们。

当我们不需要从这些缓冲中采样的时候,通常都会选择渲染缓冲对象,因为它会更优化一点。

==创建一个深度和模板渲染缓冲对象==可以通过调用glRenderbufferStorage函数来完成:

1
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);

创建一个渲染缓冲对象和纹理对象类似,不同的是这个对象是专门被设计作为帧缓冲附件使用的,而不是纹理那样的通用数据缓冲(General Purpose Data Buffer)。

这里我们选择GL_DEPTH24_STENCIL8作为内部格式,它封装了24位的深度和8位的模板缓冲。


最后一件事就是==附加==这个渲染缓冲对象:

1
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

glFramebufferRenderbuffer函数:

  • target:帧缓冲的目标(绘制、读取或两者皆有)
  • attachment:我们想要附加的附件类型。当前附件一个深度模板附件
  • buffer target:附加渲染缓冲对象类型
  • buffer object:渲染缓冲对象本身

小结

两种缓冲附件适用场景

渲染缓冲对象能为你的帧缓冲对象提供一些优化,但知道什么时候使用渲染缓冲对象,什么时候使用纹理是很重要的。

通常的规则是:

  • 如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。
  • 如果你需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。性能方面它不会产生非常大的影响的。

渲染缓冲对象疑惑

本猜想属于超纲范围,对于理解本节知识完全不需要知道这些细节。这里只是本人的一些思考罢了。

  • 见识还是太少,例子太少。局限性很大,不能很好理解掌握。

对纹理附件很好理解,就是绑定帧缓冲之后,把相关信息写到一个纹理中,后续可以采样使用。

但对渲染缓冲对象一直没法很好的理解。说不上来的感觉就是很抽象,很没道理。

疑问点:渲染缓冲对象究竟是一种什么样的存在?

已知:只知道rbo是原生OpenGL格式,更快,不能采样,目前主要用于深度、模板测试。其他没了,感觉使用起来很固定。其他使用,只能等后续继续完善这块内容啦。

image
### 实践:渲染到纹理

既然我们已经知道帧缓冲(大概)是怎么工作的了,是时候实践它们了。

我们将会将场景渲染到一个附加到帧缓冲对象上的==颜色纹理==中,之后将在一个横跨整个屏幕的四边形上绘制这个纹理。

这样视觉输出和没使用帧缓冲时是完全一样的,但这次是打印到了一个四边形上。这为什么很有用呢?我们会在下一部分中知道原因。

首先要创建一个帧缓冲对象,并绑定它,这些都很直观:

1
2
3
unsigned int framebuffer;
glGenFramebuffers(1, &framebuffer);
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer);

接下来我们需要创建一个纹理图像,我们将它作为一个颜色附件附加到帧缓冲上。

我们将纹理的维度设置为窗口的宽度和高度,并且不初始化它的数据:

1
2
3
4
5
6
7
8
9
10
11
// 生成纹理
unsigned int texColorBuffer;
glGenTextures(1, &texColorBuffer);
glBindTexture(GL_TEXTURE_2D, texColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR );
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);

// 将它附加到当前绑定的帧缓冲对象
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texColorBuffer, 0);

我们还希望OpenGL能够进行深度测试(如果你需要的话还有模板测试),所以我们还需要添加一个深度(和模板)附件到帧缓冲中。

由于我们只希望采样颜色缓冲,而不是其它的缓冲,我们可以为它们创建一个渲染缓冲对象。

我们将它创建为一个深度模板附件渲染缓冲对象。我们将它的内部格式设置为GL_DEPTH24_STENCIL8,对我们来说这个精度已经足够了。

1
2
3
4
5
unsigned int rbo;
glGenRenderbuffers(1, &rbo);
glBindRenderbuffer(GL_RENDERBUFFER, rbo);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, 800, 600);
glBindRenderbuffer(GL_RENDERBUFFER, 0);

当我们为渲染缓冲对象分配了足够的内存之后,我们可以解绑这个渲染缓冲。

接下来,作为完成帧缓冲之前的最后一步,我们将渲染缓冲对象附加到帧缓冲的深度模板附件上:

1
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, rbo);

最后,我们希望检查帧缓冲是否是完整的,如果不是,我们将打印错误信息。

1
2
3
if(glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
glBindFramebuffer(GL_FRAMEBUFFER, 0);

记得要解绑帧缓冲,保证我们不会不小心渲染到错误的帧缓冲上。


现在这个帧缓冲就完整了,我们只需要绑定这个帧缓冲对象,让渲染到帧缓冲的缓冲中而不是默认的帧缓冲中。

之后的渲染指令将会影响当前绑定的帧缓冲。所有的深度和模板操作都会从当前绑定的帧缓冲的深度和模板附件中(如果有的话)读取。

如果你忽略了深度缓冲,那么所有的深度测试操作将不再工作,因为当前绑定的帧缓冲中不存在深度缓冲。

所以,要想绘制场景到一个纹理上,我们需要采取以下的步骤:

  1. 将新的帧缓冲绑定为激活的帧缓冲,和往常一样渲染场景
  2. 绑定默认的帧缓冲
  3. 绘制一个横跨整个屏幕的四边形,将帧缓冲的颜色缓冲作为它的纹理。

为了绘制这个四边形,我们将会新创建一套简单的着色器。我们将不会包含任何花哨的矩阵变换,因为我们提供的是标准化设备坐标的顶点坐标,所以我们可以直接将它们设定为顶点着色器的输出。顶点着色器是这样的:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aTexCoords;

out vec2 TexCoords;

void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
TexCoords = aTexCoords;
}

并没有太复杂的东西。片段着色器会更加基础,我们做的唯一一件事就是从纹理中采样:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D screenTexture;

void main()
{
FragColor = texture(screenTexture, TexCoords);
}

接着就靠你来为屏幕四边形创建并配置一个VAO了。帧缓冲的一个渲染迭代将会有以下的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第一处理阶段(Pass)
glBindFramebuffer(GL_FRAMEBUFFER, framebuffer); // 使用自定义帧缓冲
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 我们现在不使用模板缓冲
glEnable(GL_DEPTH_TEST);
DrawScene();

// 第二处理阶段
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 返回默认帧缓冲
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);

screenShader.use();
glBindVertexArray(quadVAO);
glDisable(GL_DEPTH_TEST);
glBindTexture(GL_TEXTURE_2D, textureColorbuffer);
glDrawArrays(GL_TRIANGLES, 0, 6);

要注意一些事情。

  • 第一,由于我们使用的每个帧缓冲都有它自己一套缓冲,我们希望设置合适的位,调用glClear,清除这些缓冲。
  • 第二,当绘制四边形时,我们将禁用深度测试,因为我们是在绘制一个简单的四边形,并不需要关系深度测试。在绘制普通场景的时候我们将会重新启用深度测试。
image

后期处理

反相

我们将会从屏幕纹理中取颜色值,然后用1.0减去它,对它进行反相:

1
2
3
4
void main()
{
FragColor = vec4(vec3(1.0 - texture(screenTexture, TexCoords)), 1.0);
}

尽管反相是一个相对简单的后期处理效果,它已经能创造一些奇怪的效果了:

image

灰度

另外一个很有趣的效果是,移除场景中除了黑白灰以外所有的颜色,让整个图像灰度化(Grayscale)。很简单的实现方式是,取所有的颜色分量,将它们平均化:

1
2
3
4
5
6
void main()
{
FragColor = texture(screenTexture, TexCoords);
float average = (FragColor.r + FragColor.g + FragColor.b) / 3.0;
FragColor = vec4(average, average, average, 1.0);
}

这已经能创造很好的结果了,但人眼会对绿色更加敏感一些,而对蓝色不那么敏感,所以为了获取物理上更精确的效果,我们需要使用加权的(Weighted)通道:

1
2
3
4
5
6
void main()
{
FragColor = texture(screenTexture, TexCoords);
float average = 0.2126 * FragColor.r + 0.7152 * FragColor.g + 0.0722 * FragColor.b;
FragColor = vec4(average, average, average, 1.0);
}
image

你可能不会立刻发现有什么差别,但在更复杂的场景中,这样的加权灰度效果会更真实一点。

核效果

在一个纹理图像上做后期处理的另外一个好处是,我们可以从纹理的其它地方采样颜色值。

  • 比如说我们可以在当前纹理坐标的周围取一小块区域,对当前纹理值周围的多个纹理值进行采样。我们可以结合它们创建出很有意思的效果。

核(Kernel)(或卷积矩阵(Convolution Matrix))是一个类矩阵的数值数组,它的中心为当前的像素,它会用它的核值乘以周围的像素值,并将结果相加变成一个值。

所以,基本上我们是在对当前像素周围的纹理坐标添加一个小的偏移量,并根据核将结果合并。下面是核的一个例子:

![image-20230408170211668](Learn OpenGL.assets/image-20230408170211668.png)

这个核取了8个周围像素值,将它们乘以2,而把当前的像素乘以-15。

这个核的例子将周围的像素乘上了一个权重,并将当前像素乘以一个比较大的负权重来平衡结果。

image

核是后期处理一个非常有用的工具,它们使用和实验起来都很简单,网上也能找到很多例子。我们需要稍微修改一下片段着色器,让它能够支持核。我们假设使用的核都是3x3核(实际上大部分核都是):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const float offset = 1.0 / 300.0;  

void main()
{
vec2 offsets[9] = vec2[](
vec2(-offset, offset), // 左上
vec2( 0.0f, offset), // 正上
vec2( offset, offset), // 右上
vec2(-offset, 0.0f), // 左
vec2( 0.0f, 0.0f), // 中
vec2( offset, 0.0f), // 右
vec2(-offset, -offset), // 左下
vec2( 0.0f, -offset), // 正下
vec2( offset, -offset) // 右下
);

float kernel[9] = float[](
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
);

vec3 sampleTex[9];
for(int i = 0; i < 9; i++)
{
sampleTex[i] = vec3(texture(screenTexture, TexCoords.st + offsets[i]));
}
vec3 col = vec3(0.0);
for(int i = 0; i < 9; i++)
col += sampleTex[i] * kernel[i];

FragColor = vec4(col, 1.0);
}

在这个例子中是一个锐化(Sharpen)核,它会采样周围的所有像素,锐化每个颜色值。

这个锐化核看起来是这样的:

image
模糊

创建模糊(Blur)效果的核是这样的:

![image-20230408170457646](Learn OpenGL.assets/image-20230408170457646.png)

由于所有值的和是16,所以直接返回合并的采样颜色将产生非常亮的颜色,所以我们需要将核的每个值都除以16。最终的核数组将会是:

1
2
3
4
5
float kernel[9] = float[](
1.0 / 16, 2.0 / 16, 1.0 / 16,
2.0 / 16, 4.0 / 16, 2.0 / 16,
1.0 / 16, 2.0 / 16, 1.0 / 16
);

通过在片段着色器中改变核的float数组,我们完全改变了后期处理效果。它现在看起来是这样子的:

image

这样的模糊效果创造了很多的可能性。我们可以随着时间修改模糊的量,创造出玩家醉酒时的效果,或者在主角没带眼镜的时候增加模糊。模糊也能够让我们来平滑颜色值,我们将在之后教程中使用到。

边缘检测

下面的边缘检测(Edge-detection)核和锐化核非常相似:

![image-20230408170628767](Learn OpenGL.assets/image-20230408170628767.png)

这个核高亮了所有的边缘,而暗化了其它部分,在我们只关心图像的边角的时候是非常有用的。

image image

4.6 立方体贴图

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/06%20Cubemaps/

我们已经使用2D纹理很长时间了,但除此之外仍有更多的纹理类型等着我们探索。

在本节中,我们将讨论的是将多个纹理组合起来映射到一张纹理上的一种纹理类型:==立方体贴图(Cube Map)==。

简单来说,立方体贴图就是一个包含了6个2D纹理的纹理,每个2D纹理都组成了立方体的一个面:一个有纹理的立方体。

立方体贴图有一个非常有用的特性,它可以通过一个==方向向量来进行索引/采样==。

假设我们有一个1x1x1的单位立方体,方向向量的原点位于它的中心。使用一个橘黄色的方向向量来从立方体贴图上采样一个纹理值会像是这样:

image

方向向量的大小并不重要,只要提供了方向,OpenGL就会获取方向向量(最终)所击中的纹素,并返回对应的采样纹理值。

创建立方体贴图

立方体贴图是和其它纹理一样的,所以如果想创建一个立方体贴图的话,我们需要生成一个纹理,并将其绑定到纹理目标上,之后再做其它的纹理操作。

这次要绑定到GL_TEXTURE_CUBE_MAP:

1
2
3
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

因为立方体贴图包含有6个纹理,每个面一个,我们需要调用glTexImage2D函数6次,参数和之前教程中很类似。

但这一次我们将纹理目标(target)参数设置为立方体贴图的一个特定的面,告诉OpenGL我们在对立方体贴图的哪一个面创建纹理。

这就意味着我们需要对立方体贴图的每一个面都调用一次glTexImage2D。

image

和OpenGL的很多枚举(Enum)一样,它们背后的int值是线性递增的,所以如果我们有一个纹理位置的数组或者vector,我们就可以从GL_TEXTURE_CUBE_MAP_POSITIVE_X开始遍历它们,在每个迭代中对枚举值加1,遍历了整个纹理目标:

1
2
3
4
5
6
7
8
9
10
int width, height, nrChannels;
unsigned char *data;
for(unsigned int i = 0; i < textures_faces.size(); i++)
{
data = stbi_load(textures_faces[i].c_str(), &width, &height, &nrChannels, 0);
glTexImage2D(
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
}

这里我们有一个叫做textures_faces的vector,它包含了立方体贴图所需的所有纹理路径,并以表中的顺序排列。

这将为当前绑定的立方体贴图中的每个面生成一个纹理。


因为立方体贴图和其它纹理没什么不同,我们也需要设定它的环绕和过滤方式:

1
2
3
4
5
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

不要被GL_TEXTURE_WRAP_R吓到,它仅仅是为纹理的R坐标设置了环绕方式,它对应的是纹理的第三个维度(和位置的z一样)。

我们将环绕方式设置为GL_CLAMP_TO_EDGE,这是因为正好处于两个面之间的纹理坐标可能不能击中一个面(由于一些硬件限制),所以通过使用GL_CLAMP_TO_EDGE,OpenGL将在我们对两个面之间采样的时候,永远返回它们的边界值。

在绘制使用立方体贴图的物体之前,我们要先激活对应的纹理单元,并绑定立方体贴图,这和普通的2D纹理没什么区别。


在片段着色器中,我们使用了一个不同类型的采样器,samplerCube,我们将使用texture函数使用它进行采样,但这次我们将使用一个vec3的方向向量而不是vec2。使用立方体贴图的片段着色器会像是这样的:

1
2
3
4
5
6
7
in vec3 textureDir; // 代表3D纹理坐标的方向向量
uniform samplerCube cubemap; // 立方体贴图的纹理采样器

void main()
{
FragColor = texture(cubemap, textureDir);
}

天空盒

天空盒是一个包含了整个场景的(大)立方体,它包含周围环境的6个图像,让玩家以为他处在一个比实际大得多的环境当中。游戏中使用天空盒的例子有群山、白云或星空。

你可以在网上找到很多像这样的天空盒资源。比如说这个网站就提供了很多天空盒。天空盒图像通常有以下的形式:

image

如果你将这六个面折成一个立方体,你就会得到一个完全贴图的立方体,模拟一个巨大的场景。

一些资源可能会提供了这样格式的天空盒,你必须手动提取六个面的图像,但在大部分情况下它们都是6张单独的纹理图像。

之后我们将在场景中使用这个(高质量的)天空盒,它可以在这里下载到。

加载天空盒

因为天空盒本身就是一个立方体贴图,加载天空盒和之前加载立方体贴图时并没有什么不同。为了加载天空盒,我们将使用下面的函数,它接受一个包含6个纹理路径的vector:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
unsigned int loadCubemap(vector<std::string> faces)
{
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

int width, height, nrChannels;
for (unsigned int i = 0; i < faces.size(); i++)
{
unsigned char *data = stbi_load(faces[i].c_str(), &width, &height, &nrChannels, 0);
if (data)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i,
0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
);
stbi_image_free(data);
}
else
{
std::cout << "Cubemap texture failed to load at path: " << faces[i] << std::endl;
stbi_image_free(data);
}
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

return textureID;
}

函数本身应该很熟悉了。它基本就是上一部分中立方体贴图的代码,只不过合并到了一个便于管理的函数中。


之后,在调用这个函数之前,我们需要将合适的纹理路径按照立方体贴图枚举指定的顺序加载到一个vector中。

image

现在我们就将这个天空盒加载为一个立方体贴图了,它的id是cubemapTexture。我们可以将它绑定到一个立方体中,替换掉用了很长时间的难看的纯色背景。

显示天空盒

由于天空盒是绘制在一个立方体上的,和其它物体一样,我们需要另一个VAO、VBO以及新的一组顶点。你可以在这里找到它的顶点数据。

用于贴图3D立方体的立方体贴图可以使用立方体的位置作为纹理坐标来采样。

  • 当立方体处于原点(0, 0, 0)时,它的每一个位置向量都是从原点出发的方向向量。

要渲染天空盒的话,我们需要一组新的着色器,它们都不是很复杂。因为我们只有一个顶点属性,顶点着色器非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec3 aPos;

out vec3 TexCoords;

uniform mat4 projection;
uniform mat4 view;

void main()
{
TexCoords = aPos;
gl_Position = projection * view * vec4(aPos, 1.0);
}

注意,顶点着色器中很有意思的部分是,我们将输入的位置向量作为输出给片段着色器的纹理坐标。片段着色器会将它作为输入来采样samplerCube

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 FragColor;

in vec3 TexCoords;

uniform samplerCube skybox;

void main()
{
FragColor = texture(skybox, TexCoords);
}

片段着色器非常直观。我们将顶点属性的位置向量作为纹理的方向向量,并使用它从立方体贴图中采样纹理值。


有了立方体贴图纹理,渲染天空盒现在就非常简单了,我们只需要绑定立方体贴图纹理,skybox采样器就会自动填充上天空盒立方体贴图了。

==绘制天空盒==时,我们需要将它变为场景中的==第一个渲染的物体==,并且==禁用深度写入==。这样子天空盒就会永远被绘制在其它物体的背后了。

1
2
3
4
5
6
7
8
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... 设置观察和投影矩阵
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 绘制剩下的场景

如果你运行一下的话你就会发现出现了一些问题。

我们希望天空盒是以玩家为中心的,这样不论玩家移动了多远,天空盒都不会变近,让玩家产生周围环境非常大的印象。

然而,当前的观察矩阵会旋转、缩放和位移来变换天空盒的所有位置,所以当玩家移动的时候,立方体贴图也会移动!

我们希望==移除观察矩阵中的位移部分==,让移动不会影响天空盒的位置向量。

我们通过取4x4矩阵左上角的3x3矩阵来移除变换矩阵的位移部分。我们可以将观察矩阵转换为3x3矩阵(移除位移),再将其转换回4x4矩阵,来达到类似的效果。

1
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix())); //观察矩阵是以原点为中心,Cube去掉了位移,故也是以原点为中心

这将移除任何的位移,但保留旋转变换,让玩家仍然能够环顾场景。

不移除位移的影响,就是如下效果

image

有了天空盒,最终的效果就是一个看起来巨大的场景了。如果你在箱子周围转一转,你就能立刻感受到距离感,极大地提升了场景的真实度。最终的结果看起来是这样的:

image

优化

目前我们是首先渲染天空盒,之后再渲染场景中的其它物体。这样子能够工作,但不是非常高效。

如果我们先渲染天空盒,我们就会对屏幕上的每一个像素运行一遍片段着色器,即便只有一小部分的天空盒最终是可见的。

可以使用==提前深度测试==(Early Depth Testing)轻松丢弃掉的片段能够节省我们很多宝贵的带宽。

所以,我们将会最后渲染天空盒,以获得轻微的性能提升。(OpenGL自动进行)

这样子的话,深度缓冲就会填充满所有物体的深度值了,我们只需要在提前深度测试通过的地方渲染天空盒的片段就可以了,很大程度上减少了片段着色器的调用。

问题是,天空盒很可能会渲染在所有其他对象之上,因为它只是一个1x1x1的立方体(译注:意味着距离摄像机的距离也只有1),会通过大部分的深度测试。

不用深度测试来进行渲染不是解决方案,因为天空盒将会复写场景中的其它物体。

我们需要==欺骗深度缓冲==,让它认为==天空盒有着最大的深度值1.0==,只要它前面有一个物体,深度测试就会失败。


在[坐标系统](https://learnopengl-cn.github.io/01 Getting started/08 Coordinate Systems/)小节中我们说过,透视除法是在顶点着色器运行之后执行的,将gl_Position的xyz坐标除以w分量。

我们又从[深度测试](https://learnopengl-cn.github.io/04 Advanced OpenGL/01 Depth testing/)小节中知道,相除结果的z分量等于顶点的深度值。

使用这些信息,我们可以将输出位置的==z分量等于它的w分量,让z分量永远等于1.0==,这样子的话,当透视除法执行之后,z分量会变为w / w = 1.0

1
2
3
4
5
6
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}

最终的标准化设备坐标将永远会有一个等于1.0的z值:最大的深度值。结果就是天空盒只会在没有可见物体的地方渲染了(只有这样才能通过深度测试,其它所有的东西都在天空盒前面)。

我们还要改变一下深度函数,将它从默认的GL_LESS改为GL_LEQUAL。深度缓冲将会填充上天空盒的1.0值,所以我们需要保证天空盒在值小于或等于深度缓冲而不是小于时通过深度测试。

环境映射

我们现在将整个环境映射到了一个纹理对象上了,能利用这个信息的不仅仅只有天空盒。

通过使用环境的立方体贴图,我们可以给物体反射和折射的属性。

这样使用环境立方体贴图的技术叫做==环境映射==(Environment Mapping),其中最流行的两个是==反射==(Reflection)和==折射==(Refraction)。

反射

反射这个属性表现为物体(或物体的一部分)反射它周围环境,即根据观察者的视角,物体的颜色或多或少等于它的环境。

镜子就是一个反射性物体:它会根据观察者的视角反射它周围的环境。

反射的原理并不难。下面这张图展示了我们如何计算反射向量,并如何使用这个向量来从立方体贴图中采样:

image

我们根据观察方向向量$ \overline{I} $和物体的法向量$ \overline{N} $,来计算反射向量$ \overline{R} $。我们可以使用GLSL内建的reflect函数来计算这个反射向量。

最终的$ \overline{R} $向量将会作为索引/采样立方体贴图的方向向量,返回环境的颜色值。最终的结果是物体看起来反射了天空盒。

因为我们已经在场景中配置好天空盒了,创建反射效果并不会很难。我们将会改变箱子的片段着色器,让箱子有反射性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
out vec4 FragColor;

in vec3 Normal;
in vec3 Position;

uniform vec3 cameraPos;
uniform samplerCube skybox;

void main()
{
vec3 I = normalize(Position - cameraPos);
vec3 R = reflect(I, normalize(Normal));
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}

我们先计算了观察/摄像机方向向量I,并使用它来计算反射向量R,之后我们将使用R来从天空盒立方体贴图中采样。

注意,我们现在又有了片段的插值Normal和Position变量,所以我们需要更新一下顶点着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out vec3 Normal;
out vec3 Position;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main()
{
Normal = mat3(transpose(inverse(model))) * aNormal;
Position = vec3(model * vec4(aPos, 1.0));
gl_Position = projection * view * model * vec4(aPos, 1.0);
}

我们现在使用了一个法向量,所以我们将再次使用法线矩阵(Normal Matrix)来变换它们。

Position输出向量是一个世界空间的位置向量。顶点着色器的这个Position输出将用来在片段着色器内计算观察方向向量。

因为我们使用了法线,你还需要更新一下顶点数据,并更新属性指针。还要记得去设置cameraPos这个uniform。


接下来,我们在渲染箱子之前先绑定立方体贴图纹理:

1
2
3
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);

编译并运行代码,你将会得到一个像是镜子一样的箱子。周围的天空盒被完美地反射在箱子上。

image

折射

环境映射的另一种形式是折射,它和反射很相似。折射是光线由于传播介质的改变而产生的方向变化。

在常见的类水表面上所产生的现象就是折射,光线不是直直地传播,而是弯曲了一点。将你的半只胳膊伸进水里,观察出来的就是这种效果。

折射是通过斯涅尔定律(Snell’s Law)来描述的,使用环境贴图的话看起来像是这样:

image

同样,我们有一个观察向量$\overline{I}$,一个法向量$\overline{N}$,而这次是折射向量$\overline{R}$。

可以看到,观察向量的方向轻微弯曲了。弯折后的向量$\overline{R}$将会用来从立方体贴图中采样。

折射可以使用GLSL的内建refract函数来轻松实现,它需要一个法向量、一个观察方向和两个材质之间的折射率(Refractive Index)。

折射率决定了材质中光线弯曲的程度,每个材质都有自己的折射率。一些最常见的折射率可以在下表中找到:

image

我们使用这些折射率来计算光传播的两种材质间的比值。在我们的例子中,光线/视线从空气进入玻璃(如果我们假设箱子是玻璃制的),所以比值为1.001.52=0.6581.001.52=0.658。

我们已经绑定了立方体贴图,提供了顶点数据和法线,并设置了摄像机位置的uniform。唯一要修改的就是片段着色器:

1
2
3
4
5
6
7
void main()
{
float ratio = 1.00 / 1.52;
vec3 I = normalize(Position - cameraPos);
vec3 R = refract(I, normalize(Normal), ratio);
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}

通过改变折射率,你可以创建完全不同的视觉效果。

动态环境贴图

现在我们使用的都是静态图像的组合来作为天空盒,看起来很不错,但它没有在场景中包括可移动的物体。

通过使用==帧缓冲==,我们能够为物体的6个不同角度创建出场景的纹理,并在每个渲染迭代中将它们储存到一个立方体贴图中。

之后我们就可以使用这个(动态生成的)立方体贴图来创建出更真实的,包含其它物体的,反射和折射表面了。

这就叫做==动态环境映射==(Dynamic Environment Mapping),因为我们动态创建了物体周围的立方体贴图,并将其用作环境贴图。

虽然它看起来很棒,但它有一个很大的缺点:我们需要为使用环境贴图的物体渲染场景6次,这是对程序是非常大的性能开销。

现代的程序通常会尽可能使用天空盒,并在可能的时候使用预编译的立方体贴图,只要它们能产生一点动态环境贴图的效果。

虽然动态环境贴图是一个很棒的技术,但是要想在不降低性能的情况下让它工作还是需要非常多的技巧的。

4.7 高级数据

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/07%20Advanced%20Data/

这一节中,我们将讨论一些更有意思的缓冲函数,以及我们该如何==使用纹理对象来储存大量的数据==(纹理的部分还没有完成)。

填充缓冲到指定区域

OpenGL中的缓冲只是一个==管理特定内存块的对象==。

在我们将它绑定到一个缓冲目标(Buffer Target)时,我们才赋予了其意义。

  • 当我们绑定一个缓冲到GL_ARRAY_BUFFER时,它就是一个顶点数组缓冲,但我们也可以很容易地将其绑定到GL_ELEMENT_ARRAY_BUFFER。

OpenGL内部会为每个目标储存一个缓冲,并且会根据目标的不同,以不同的方式处理缓冲。


到目前为止,我们一直是调用glBufferData==函数来填充缓冲对象所管理的内存==,这个函数会分配一块内存,并将数据添加到这块内存中。

  • 如果我们将它的data参数设置为NULL,那么这个函数将只会分配内存,但不进行填充。这在我们需要预留(Reserve)特定大小的内存,之后回到这个缓冲一点一点填充的时候会很有用。

方式一

除了使用一次函数调用填充整个缓冲之外,我们也可以使用glBufferSubData,==填充缓冲的特定区域==。

  • 这个函数需要==一个缓冲目标==、==一个偏移量==、==数据的大小==和==数据本身==作为它的参数。
  • 这个函数不同的地方在于,我们可以提供一个偏移量,指定从何处开始填充这个缓冲。这能够让我们插入或者更新缓冲内存的某一部分。
  • 要注意的是,缓冲需要有足够的已分配内存,所以对一个缓冲调用glBufferSubData之前必须要先调用glBufferData
1
glBufferSubData(GL_ARRAY_BUFFER, 24, sizeof(data), &data); // 范围: [24, 24 + sizeof(data)]

方式二

将数据导入缓冲的另外一种方法是,请求缓冲内存的指针,直接将数据复制到缓冲当中。通过调用glMapBuffer函数,OpenGL会返回当前绑定缓冲的内存指针,供我们操作:

1
2
3
4
5
6
7
8
9
10
11
float data[] = {
0.5f, 1.0f, -0.35f
...
};
glBindBuffer(GL_ARRAY_BUFFER, buffer);
// 获取指针
void *ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY);
// 复制数据到内存
memcpy(ptr, data, sizeof(data));
// 记得告诉OpenGL我们不再需要这个指针了
glUnmapBuffer(GL_ARRAY_BUFFER);

当我们使用glUnmapBuffer函数,告诉OpenGL我们已经完成指针操作之后,OpenGL就会知道你已经完成了。

  • 在解除映射(Unmapping)之后,指针将会不再可用,并且如果OpenGL能够成功将您的数据映射到缓冲中,这个函数将会返回GL_TRUE

如果要直接映射数据到缓冲,而不事先将其存储到临时内存中,glMapBuffer这个函数会很有用。比如说,你可以从文件中读取数据,并直接将它们复制到缓冲内存中。

分批顶点属性

通过使用glVertexAttribPointer,我们能够==指定顶点数组缓冲内容的属性布局==。

在顶点数组缓冲中,我们对属性进行了==交错(Interleave)处理==,也就是说,我们将每一个顶点的位置、法线和/或纹理坐标==紧密==放置在一起。既然我们现在已经对缓冲有了更多的了解,我们可以采取另一种方式。

我们可以做的是,将每一种属性类型的向量数据打包(Batch)为一个大的区块,而不是对它们进行交错储存。与交错布局123123123123不同,我们将采用==分批==(Batched)的方式111122223333。

当从文件中加载顶点数据的时候,你通常获取到的是一个位置数组、一个法线数组和/或一个纹理坐标数组。我们需要花点力气才能将这些数组转化为一个大的交错数据数组。使用分批的方式会是更简单的解决方案,我们可以很容易使用glBufferSubData函数实现:

1
2
3
4
5
6
7
float positions[] = { ... };
float normals[] = { ... };
float tex[] = { ... };
// 填充缓冲
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(positions), &positions);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions), sizeof(normals), &normals);
glBufferSubData(GL_ARRAY_BUFFER, sizeof(positions) + sizeof(normals), sizeof(tex), &tex);

这样子我们就能直接将属性数组作为一个整体传递给缓冲,而不需要事先处理它们了。我们仍可以将它们合并为一个大的数组,再使用glBufferData来填充缓冲,但对于这种工作,使用glBufferSubData会更合适一点。

我们还需要更新顶点属性指针来反映这些改变:

1
2
3
4
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), 0);  
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)(sizeof(positions)));
glVertexAttribPointer(
2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)(sizeof(positions) + sizeof(normals)));

注意stride参数等于顶点属性的大小,因为下一个顶点属性向量能在3个(或2个)分量之后找到。

这给了我们设置顶点属性的另一种方法。使用哪种方法都不会对OpenGL有什么立刻的好处,它只是设置顶点属性的一种更整洁的方式。具体使用的方法将完全取决于你的喜好与程序类型。

复制缓冲

当你的缓冲已经填充好数据之后,你可能会想与其它的缓冲共享其中的数据,或者想要将缓冲的内容复制到另一个缓冲当中。glCopyBufferSubData能够让我们相对容易地==从一个缓冲中复制数据到另一个缓冲中==。这个函数的原型如下:

1
2
void glCopyBufferSubData(GLenum readtarget, GLenum writetarget, GLintptr readoffset,
GLintptr writeoffset, GLsizeiptr size);
  • readtargetwritetarget参数需要填入复制源和复制目标的缓冲目标。比如说,我们可以将VERTEX_ARRAY_BUFFER缓冲复制到VERTEX_ELEMENT_ARRAY_BUFFER缓冲,分别将这些缓冲目标设置为读和写的目标。当前绑定到这些缓冲目标的缓冲将会被影响到。
    • 但如果我们想读写数据的两个不同缓冲都为顶点数组缓冲该怎么办呢?
    • 我们不能同时将两个缓冲绑定到同一个缓冲目标上。OpenGL提供给我们另外两个缓冲目标,叫做GL_COPY_READ_BUFFERGL_COPY_WRITE_BUFFER。可以将需要的缓冲绑定到这两个缓冲目标上,并将这两个目标作为readtargetwritetarget参数。
  • 接下来glCopyBufferSubData会从readtarget中读取size大小的数据,并将其写入writetarget缓冲的writeoffset偏移量处。
1
2
3
4
float vertexData[] = { ... };
glBindBuffer(GL_COPY_READ_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_COPY_READ_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

我们也可以只将writetarget缓冲绑定为新的缓冲目标类型之一:

1
2
3
4
float vertexData[] = { ... };
glBindBuffer(GL_ARRAY_BUFFER, vbo1);
glBindBuffer(GL_COPY_WRITE_BUFFER, vbo2);
glCopyBufferSubData(GL_ARRAY_BUFFER, GL_COPY_WRITE_BUFFER, 0, 0, sizeof(vertexData));

4.8 高级GLSL

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/08%20Advanced%20GLSL/

我们将会讨论一些有趣的内建变量(Built-in Variable),管理着色器输入和输出的新方式以及一个叫做Uniform缓冲对象(Uniform Buffer Object)的有用工具。

1.GLSL的内建变量

GLSL还定义了另外几个以gl_为前缀的变量,它们能提供给我们更多的方式来读取/写入数据。我们已经在前面教程中接触过其中的两个了:顶点着色器的输出向量gl_Position,和片段着色器的gl_FragCoord

我们将会讨论几个有趣的GLSL内建输入和输出变量,并会解释它们能够怎样帮助你。注意,我们将不会讨论GLSL中存在的所有内建变量,如果你想知道所有的内建变量的话,请查看OpenGL的wiki

顶点着色器变量

我们已经见过gl_Position了,它是顶点着色器的裁剪空间输出位置向量。如果你想在屏幕上显示任何东西,在顶点着色器中设置gl_Position是必须的步骤。

gl_PointSize

我们能够选用的其中一个图元是GL_POINTS,如果使用它的话,每一个顶点都是一个图元,都会被渲染为一个点。我们可以通过OpenGL的glPointSize函数来设置渲染出来的点的大小,但我们也可以在顶点着色器中修改这个值。

gl_PointSize

我们能够选用的其中一个图元是GL_POINTS,如果使用它的话,每一个顶点都是一个图元,都会被渲染为一个点。

GLSL定义了一个叫做gl_PointSize输出变量,它是一个float变量,你可以使用它来设置点的宽高(像素)。在顶点着色器中修改点的大小的话,你就能对每个顶点设置不同的值了。

在顶点着色器中修改点大小的功能默认是禁用的,如果你需要启用它的话,你需要启用OpenGL的GL_PROGRAM_POINT_SIZE

1
glEnable(GL_PROGRAM_POINT_SIZE);

一个简单的例子就是将点的大小设置为裁剪空间位置的z值,也就是顶点距观察者的距离。点的大小会随着观察者距顶点距离变远而增大。

1
2
3
4
5
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
gl_PointSize = gl_Position.z;
}
image
gl_VertexID

gl_Position和gl_PointSize都是输出变量,因为它们的值是作为顶点着色器的输出被读取的。我们可以对它们进行写入,来改变结果。

顶点着色器还为我们提供了一个有趣的输入变量,我们只能对它进行读取,它叫做gl_VertexID。

整型变量gl_VertexID储存了正在绘制顶点的当前ID。

当(使用glDrawElements)进行索引渲染的时候,这个变量会存储正在绘制顶点的当前索引。当(使用glDrawArrays)不使用索引进行绘制的时候,这个变量会储存从渲染调用开始的已处理顶点数量。

片段着色器变量

在片段着色器中,我们也能访问到一些有趣的变量。GLSL提供给我们两个有趣的输入变量:gl_FragCoord和gl_FrontFacing。

gl_FragCoord

在讨论深度测试的时候,我们已经见过gl_FragCoord很多次了,因为gl_FragCoord的z分量等于对应片段的深度值。然而,我们也能使用它的x和y分量来实现一些有趣的效果。

gl_FragCoord的x和y分量是片段的窗口空间(Window-space)坐标,其==原点为窗口的左下角==。我们已经使用glViewport设定了一个800x600的窗口了,所以片段窗口空间坐标的x分量将在0到800之间,y分量在0到600之间。

通过利用片段着色器,我们可以根据片段的窗口坐标,计算出不同的颜色。gl_FragCoord的一个常见用处是用于对比不同片段计算的视觉输出效果,这在技术演示中可以经常看到。

比如说,我们能够将屏幕分成两部分,在窗口的左侧渲染一种输出,在窗口的右侧渲染另一种输出。下面这个例子片段着色器会根据窗口坐标输出不同的颜色:

1
2
3
4
5
6
7
void main()
{
if(gl_FragCoord.x < 400)
FragColor = vec4(1.0, 0.0, 0.0, 1.0);
else
FragColor = vec4(0.0, 1.0, 0.0, 1.0);
}

因为窗口的宽度是800。当一个像素的x坐标小于400时,它一定在窗口的左侧,所以我们给它一个不同的颜色。

image
gl_FrontFacing

片段着色器另外一个很有意思的输入变量是gl_FrontFacing。

在[面剔除](https://learnopengl-cn.github.io/04 Advanced OpenGL/04 Face culling/)教程中,我们提到OpenGL能够根据顶点的环绕顺序来决定一个面是正向还是背向面。

如果我们不(启用GL_FACE_CULL来)使用面剔除,那么gl_FrontFacing将会告诉我们当前片段是属于正向面的一部分还是背向面的一部分。举例来说,我们能够对正向面计算出不同的颜色。

gl_FrontFacing变量是一个bool,如果当前片段是正向面的一部分那么就是true,否则就是false。比如说,我们可以这样子创建一个立方体,在内部和外部使用不同的纹理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

uniform sampler2D frontTexture;
uniform sampler2D backTexture;

void main()
{
if(gl_FrontFacing)
FragColor = texture(frontTexture, TexCoords);
else
FragColor = texture(backTexture, TexCoords);
}
image
gl_FragDepth

输入变量gl_FragCoord能让我们读取当前片段的窗口空间坐标,并获取它的深度值,但是它是一个只读(Read-only)变量。我们不能修改片段的窗口空间坐标,但实际上修改片段的深度值还是可能的。

GLSL提供给我们一个叫做gl_FragDepth的输出变量,我们可以使用它来在着色器内==设置片段的深度值==。

要想设置深度值,我们直接写入一个0.0到1.0之间的float值到输出变量就可以了:

1
gl_FragDepth = 0.0; // 这个片段现在的深度值为 0.0

如果着色器没有写入值到gl_FragDepth,它会自动取用gl_FragCoord.z的值。

然而,由我们自己设置深度值有一个很大的缺点,只要我们在片段着色器中对gl_FragDepth进行==写入==,OpenGL就会(像[深度测试](https://learnopengl-cn.github.io/04 Advanced OpenGL/01 Depth testing/)小节中讨论的那样)==禁用所有的提前深度测试==(Early Depth Testing)。它被禁用的原因是,OpenGL无法在片段着色器运行之前得知片段将拥有的深度值,因为片段着色器可能会完全修改这个深度值。

在写入gl_FragDepth时,你就需要考虑到它所带来的性能影响。然而,从OpenGL 4.2起,我们仍可以对两者进行一定的调和,在片段着色器的顶部使用深度条件(Depth Condition)重新声明gl_FragDepth变量:

1
layout (depth_<condition>) out float gl_FragDepth;
image

通过将深度条件设置为greater或者less,OpenGL就能假设你只会写入比当前片段深度值更大或者更小的值了。这样子的话,当深度值比片段的深度值要==大==的时候,OpenGL仍是能够进行提前深度测试的。

下面这个例子中,我们对片段的深度值进行了==递增==,但仍然也保留了一些提前深度测试:

1
2
3
4
5
6
7
8
9
#version 420 core // 注意GLSL的版本!
out vec4 FragColor;
layout (depth_greater) out float gl_FragDepth;

void main()
{
FragColor = vec4(1.0);
gl_FragDepth = gl_FragCoord.z + 0.1;
}

注意这个特性只在OpenGL 4.2版本或以上才提供。

2.接口块

到目前为止,每当我们希望从顶点着色器向片段着色器发送数据时,我们都声明了几个对应的输入/输出变量。

将它们一个一个声明是着色器间发送数据最简单的方式了,但当程序变得更大时,你希望发送的可能就不只是几个变量了,它还可能包括数组和结构体。

为了帮助我们==管理==这些变量,GLSL为我们提供了一个叫做==接口块==(Interface Block)的东西,来方便我们组合这些变量。

接口块的声明和struct的声明有点相像,不同的是,现在根据它是一个==输入还是输出块==(Block),使用==in或out关键字==来定义的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoords;

uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out VS_OUT
{
vec2 TexCoords;
} vs_out;

void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
vs_out.TexCoords = aTexCoords;
}

这次我们声明了一个叫做vs_out的接口块,它打包了我们希望发送到下一个着色器中的所有输出变量。


之后,我们还需要在下一个着色器,即片段着色器,中定义一个输入接口块。

==块名==(Block Name)应该是和着色器中==一样==的(VS_OUT),但==实例名==(Instance Name)(顶点着色器中用的是vs_out)可以是==随意==的,但要避免使用误导性的名称,比如对实际上包含输入变量的接口块命名为fs_in。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
out vec4 FragColor;

in VS_OUT
{
vec2 TexCoords;
} fs_in;

uniform sampler2D texture;

void main()
{
FragColor = texture(texture, fs_in.TexCoords);
}

只要两个接口块的名字一样,它们对应的输入和输出将会匹配起来。这是帮助你管理代码的又一个有用特性,它在几何着色器这样穿插特定着色器阶段的场景下会很有用。

3.Uniform缓冲对象

问题引入:当使用多于一个的着色器时,尽管大部分的uniform变量都是相同的,我们还是需要不断地设置它们。

OpenGL为我们提供了一个叫做Uniform缓冲对象(Uniform Buffer Object)的工具,它允许我们定义一系列在多个着色器中相同的全局Uniform变量。当使用Uniform缓冲对象的时候,我们只需要设置相关的uniform一次

当然,我们仍需要手动设置每个着色器中不同的uniform。并且创建和配置Uniform缓冲对象会有一点繁琐。


因为Uniform缓冲对象仍是一个缓冲,我们可以使用glGenBuffers来创建它,将它绑定到GL_UNIFORM_BUFFER缓冲目标,并将所有相关的uniform数据存入缓冲。

在Uniform缓冲对象中储存数据是有一些规则的,我们将会在之后讨论它。

首先,我们将使用一个简单的顶点着色器,将projection和view矩阵存储到所谓的Uniform块(Uniform Block)中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout (location = 0) in vec3 aPos;

layout (std140) uniform Matrices
{
mat4 projection;
mat4 view;
};

uniform mat4 model;

void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
}

在我们大多数的例子中,我们都会在每个渲染迭代中,对每个着色器设置projection和view Uniform矩阵。这是利用Uniform缓冲对象的一个非常完美的例子,因为现在我们只需要存储这些矩阵一次就可以了。

这里,我们声明了一个叫做Matrices的Uniform块,它储存了两个4x4矩阵。Uniform块中的变量可以==直接访问==,不需要加块名作为前缀。

接下来,我们在OpenGL代码中将这些矩阵值存入缓冲中,每个声明了这个Uniform块的着色器都能够访问这些矩阵。

你现在可能会在想layout (std140)这个语句是什么意思。它的意思是说,当前定义的Uniform块对它的内容使用一个特定的内存布局。这个语句设置了==Uniform块布局==(Uniform Block Layout)。

Uniform块布局

Uniform块的内容是储存在一个缓冲对象中的,它实际上只是一块预留内存。

因为这块内存并不会保存它具体保存的是什么类型的数据,我们还需要告诉OpenGL内存的哪一部分对应着着色器中的哪一个uniform变量。(解释:相当于解释内存)

假设着色器中有以下的这个Uniform块:

1
2
3
4
5
6
7
8
9
layout (std140) uniform ExampleBlock
{
float value;
vec3 vector;
mat4 matrix;
float values[3];
bool boolean;
int integer;
};

我们需要知道的是每个变量的大小(字节)和(从块起始位置的)偏移量,来让我们能够按顺序将它们放进缓冲中。

每个元素的大小都是在OpenGL中有清楚地声明的,而且直接对应C++数据类型,其中向量和矩阵都是大的float数组。

OpenGL没有声明的是这些变量间的间距(Spacing)。这允许硬件能够在它认为合适的位置放置变量。

  • 比如说,一些硬件可能会将一个vec3放置在float边上。不是所有的硬件都能这样处理,可能会在附加这个float之前,先将vec3填充(Pad)为一个4个float的数组。这个特性本身很棒,但是会对我们造成麻烦。(总结:硬件存放方式没有标准)

默认情况下,GLSL会使用一个叫做==共享(Shared)布局==的Uniform内存布局,共享是因为一旦硬件定义了偏移量,它们在多个程序中是共享并一致的。

使用共享布局时,GLSL是可以为了优化而对uniform变量的位置进行变动的,只要变量的顺序保持不变。因为我们无法知道每个uniform变量的偏移量,我们也就不知道如何准确地填充我们的Uniform缓冲了。我们能够使用像是glGetUniformIndices这样的函数来查询这个信息,但这==超出本节的范围了==。


虽然共享布局给了我们很多节省空间的优化,但是我们需要查询每个uniform变量的偏移量,这会产生非常多的工作量。

通常的做法是,不使用共享布局,而是使用std140布局。

std140布局声明了每个变量的偏移量都是由一系列规则所决定的,这显式地声明了每个变量类型的内存布局。由于这是显式提及的,我们可以==手动==计算出每个变量的偏移量。

每个变量都有一个==基准对齐量==(Base Alignment),它等于一个变量在Uniform块中==所占据的空间==(包括填充量(Padding)),这个基准对齐量是使用std140布局的规则计算出来的。

接下来,对每个变量,我们再计算它的==对齐偏移量==(Aligned Offset),它是一个变量==从块起始位置的字节偏移量==。一个变量的对齐字节偏移量必须等于基准对齐量的==倍数==。

布局规则的原文可以在OpenGL的Uniform缓冲规范这里找到,但我们将会在下面列出最常见的规则。GLSL中的每个变量,比如说int、float和bool,都被定义为4字节量。每4个字节将会用一个N来表示。

image

和OpenGL大多数的规范一样,使用例子就能更容易地理解。我们会使用之前引入的那个叫做ExampleBlock的Uniform块,并使用std140布局计算出每个成员的对齐偏移量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
layout (std140) uniform ExampleBlock
{
// 基准对齐量 // 对齐偏移量
float value; // 4 // 0
vec3 vector; // 16 // 16 (必须是16的倍数,所以 4->16)
mat4 matrix; // 16 // 32 (列 0)
// 16 // 48 (列 1)
// 16 // 64 (列 2)
// 16 // 80 (列 3)
float values[3]; // 16 // 96 (values[0])
// 16 // 112 (values[1])
// 16 // 128 (values[2])
bool boolean; // 4 // 144
int integer; // 4 // 148
};

作为练习,尝试去自己计算一下偏移量,并和表格进行对比。使用计算后的偏移量值,根据std140布局的规则,我们就能使用像是glBufferSubData的函数将变量数据按照偏移量填充进缓冲中了。虽然std140布局不是最高效的布局,但它保证了内存布局在每个声明了这个Uniform块的程序中是==一致==的。

通过在Uniform块定义之前添加layout (std140)语句,我们告诉OpenGL这个Uniform块使用的是std140布局。除此之外还可以选择两个布局,但它们都需要我们在填充缓冲之前先==查询每个偏移量==。

我们已经见过shared布局了,剩下的一个布局是packed。当使用紧凑(Packed)布局时,是不能保证这个布局在每个程序中保持不变的(即非共享),因为它允许编译器去将uniform变量从Uniform块中优化掉,这在每个着色器中都可能是不同的。(解释:shared和packed在内存一致性方面都不太行)

使用Uniform缓冲

我们已经讨论了如何在着色器中定义Uniform块,并设定它们的内存布局了,但我们还没有讨论该如何使用它们。

首先,我们需要调用glGenBuffers,创建一个Uniform缓冲对象。一旦我们有了一个缓冲对象,我们需要将它绑定到GL_UNIFORM_BUFFER目标,并调用glBufferData,分配足够的内存。

1
2
3
4
5
unsigned int uboExampleBlock;
glGenBuffers(1, &uboExampleBlock);
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
glBufferData(GL_UNIFORM_BUFFER, 152, NULL, GL_STATIC_DRAW); // 分配152字节的内存
glBindBuffer(GL_UNIFORM_BUFFER, 0);

现在,每当我们需要对缓冲更新或者插入数据,我们都会绑定到uboExampleBlock,并使用glBufferSubData来更新它的内存。我们只需要更新这个Uniform缓冲一次,所有使用这个缓冲的着色器就都使用的是更新后的数据了。

但是,如何才能让OpenGL知道哪个Uniform缓冲对应的是哪个Uniform块呢?(解释:Uniform缓冲是GPU显存上定义的,Uniform块是着色器上定义的)


在OpenGL上下文中,定义了一些==绑定点==(Binding Point),我们可以将一个Uniform缓冲链接至它。

在创建Uniform缓冲之后,我们将它绑定到其中一个绑定点上,并将着色器中的Uniform块绑定到相同的绑定点,把它们连接到一起。

下面的这个图示展示了这个:

image
  • 一个绑定点只对应一个Uniform缓冲对象
  • 一个绑定点可以对应多个Uniform块

为了将Uniform块绑定到一个特定的绑定点中,我们需要调用glUniformBlockBinding函数,它的第一个参数是一个==程序对象==,之后是一个==Uniform块索引==和==链接到的绑定点==。

==Uniform块索引==(Uniform Block Index)是着色器中已定义Uniform块的位置值索引。这可以通过调用glGetUniformBlockIndex来获取,它接受一个==程序对象==和==Uniform块的名称==。

我们可以用以下方式将图示中的Lights Uniform块链接到绑定点2:

1
2
unsigned int lights_index = glGetUniformBlockIndex(shaderA.ID, "Lights");   
glUniformBlockBinding(shaderA.ID, lights_index, 2);

注意我们需要对每个着色器重复这一步骤。

image

layout(std140, binding = 2) uniform Lights { … };

接下来,我们还需要绑定Uniform缓冲对象到相同的绑定点上,这可以使用glBindBufferBaseglBindBufferRange来完成。

1
2
3
glBindBufferBase(GL_UNIFORM_BUFFER, 2, uboExampleBlock); 
// 或
glBindBufferRange(GL_UNIFORM_BUFFER, 2, uboExampleBlock, 0, 152);

glBindbufferBase需要一个目标,一个绑定点索引和一个Uniform缓冲对象作为它的参数。这个函数将uboExampleBlock链接到绑定点2上,自此,绑定点的两端都链接上了。

你也可以使用glBindBufferRange函数,它需要一个附加的偏移量和大小参数,这样子你可以绑定Uniform缓冲的==特定==一部分到绑定点中。通过使用glBindBufferRange函数,你可以让多个不同的Uniform块绑定到同一个Uniform缓冲对象上。(解释:一个Uniform缓冲对象可以对应多个绑定点,从而实现该效果)


现在,所有的东西都配置完毕了,我们可以开始向Uniform缓冲中添加数据了。

可以使用glBufferSubData函数,用一个字节数组添加所有的数据,或者更新缓冲的一部分。

要想更新uniform变量boolean,我们可以用以下方式更新Uniform缓冲对象:

1
2
3
4
glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
int b = true; // GLSL中的bool是4字节的,所以我们将它存为一个integer
glBufferSubData(GL_UNIFORM_BUFFER, 144, 4, &b); //手动计算
glBindBuffer(GL_UNIFORM_BUFFER, 0);

同样的步骤也能应用到Uniform块中其它的uniform变量上,但需要使用不同的范围参数。

一个简单的例子

我们回头看看之前所有的代码例子,我们不断地在使用3个矩阵:投影、观察和模型矩阵。在所有的这些矩阵中,只有模型矩阵会频繁变动。如果我们有多个着色器使用了这同一组矩阵,那么使用Uniform缓冲对象可能会更好。

我们会将投影和模型矩阵存储到一个叫做Matrices的Uniform块中。我们不会将模型矩阵存在这里,因为模型矩阵在不同的着色器中会不断改变,所以使用Uniform缓冲对象并不会带来什么好处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
layout (location = 0) in vec3 aPos;

layout (std140) uniform Matrices
{
mat4 projection;
mat4 view;
};
uniform mat4 model;

void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
}

这里没什么特别的,除了我们现在使用的是一个std140布局的Uniform块。


我们将在例子程序中,显示4个立方体,每个立方体都是使用不同的着色器程序渲染的。这4个着色器程序将使用相同的顶点着色器,但使用的是不同的片段着色器,每个着色器会输出不同的颜色。

1.绑定Uniform块到绑定点0

首先,我们将顶点着色器的Uniform块设置为绑定点0。注意我们需要对每个着色器都设置一遍。

1
2
3
4
5
6
7
8
9
unsigned int uniformBlockIndexRed    = glGetUniformBlockIndex(shaderRed.ID, "Matrices");
unsigned int uniformBlockIndexGreen = glGetUniformBlockIndex(shaderGreen.ID, "Matrices");
unsigned int uniformBlockIndexBlue = glGetUniformBlockIndex(shaderBlue.ID, "Matrices");
unsigned int uniformBlockIndexYellow = glGetUniformBlockIndex(shaderYellow.ID, "Matrices");

glUniformBlockBinding(shaderRed.ID, uniformBlockIndexRed, 0);
glUniformBlockBinding(shaderGreen.ID, uniformBlockIndexGreen, 0);
glUniformBlockBinding(shaderBlue.ID, uniformBlockIndexBlue, 0);
glUniformBlockBinding(shaderYellow.ID, uniformBlockIndexYellow, 0);

2.创建Uniform缓冲对象,绑定到绑定点0

接下来,我们创建Uniform缓冲对象本身,并将其绑定到绑定点0:

1
2
3
4
5
6
7
8
unsigned int uboMatrices
glGenBuffers(1, &uboMatrices);

glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
glBindBuffer(GL_UNIFORM_BUFFER, 0);

glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));

首先我们为缓冲分配了足够的内存,它等于glm::mat4大小的两倍。GLM矩阵类型的大小直接对应于GLSL中的mat4。接下来,我们将缓冲中的特定范围(在这里是整个缓冲)链接到绑定点0。

3.填充Uniform缓冲对象

剩余的就是填充这个缓冲了。如果我们将投影矩阵的视野(Field of View)值保持不变(所以摄像机就没有缩放了),我们只需要将其在程序中定义一次——这也意味着我们只需要将它插入到缓冲中一次。

因为我们已经为缓冲对象分配了足够的内存,我们可以使用glBufferSubData在进入渲染循环之前存储投影矩阵:

1
2
3
4
glm::mat4 projection = glm::perspective(glm::radians(45.0f), (float)width/(float)height, 0.1f, 100.0f);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(projection));
glBindBuffer(GL_UNIFORM_BUFFER, 0);

这里我们将投影矩阵储存在Uniform缓冲的前半部分。在每次渲染迭代中绘制物体之前,我们会将观察矩阵更新到缓冲的后半部分:

1
2
3
4
glm::mat4 view = camera.GetViewMatrix();           
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));
glBindBuffer(GL_UNIFORM_BUFFER, 0);

Uniform缓冲对象的部分就结束了。每个包含了Matrices这个Uniform块的顶点着色器将会包含储存在uboMatrices中的数据。所以,如果我们现在要用4个不同的着色器绘制4个立方体,它们的投影和观察矩阵都会是一样的。

1
2
3
4
5
6
7
8
9
glBindVertexArray(cubeVAO);
shaderRed.use();
glm::mat4 model;
model = glm::translate(model, glm::vec3(-0.75f, 0.75f, 0.0f)); // 移动到左上角
shaderRed.setMat4("model", model);
glDrawArrays(GL_TRIANGLES, 0, 36);
// ... 绘制绿色立方体
// ... 绘制蓝色立方体
// ... 绘制黄色立方体

唯一需要设置的uniform只剩model uniform了。在像这样的场景中使用Uniform缓冲对象会让我们在每个着色器中都剩下一些uniform调用。最终的结果会是这样的:

image

因为修改了模型矩阵,每个立方体都移动到了窗口的一边,并且由于使用了不同的片段着色器,它们的颜色也不同。

小结

这只是一个很简单的情景,我们可能会需要使用Uniform缓冲对象,但任何大型的渲染程序都可能同时激活有上百个着色器程序,这时候Uniform缓冲对象的优势就会很大地体现出来了。

Uniform缓冲对象比起独立的uniform有很多好处。

  • 第一,一次设置很多uniform会比一个一个设置多个uniform要快很多。
  • 第二,比起在多个着色器中修改同样的uniform,在Uniform缓冲中修改一次会更容易一些。
  • 最后一个好处可能不会立即显现,如果使用Uniform缓冲对象的话,你可以在着色器中使用更多的uniform。OpenGL限制了它能够处理的uniform数量,这可以通过GL_MAX_VERTEX_UNIFORM_COMPONENTS来查询。当使用Uniform缓冲对象时,最大的数量会更高。所以,当你达到了uniform的最大数量时(比如再做骨骼动画(Skeletal Animation)的时候),你总是可以选择使用Uniform缓冲对象。

4.9 几何着色器

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/09%20Geometry%20Shader/

输入:一个图元的顶点构成的数组(gl_in[]

输出:多个新图元的顶点构成的数组


在顶点和片段着色器之间有一个可选的==几何着色器==(Geometry Shader),几何着色器的==输入==是一个图元(如点或三角形)的==一组顶点==。

几何着色器可以在顶点发送到下一着色器阶段之前对它们随意变换。然而,几何着色器最有趣的地方在于,它能够将(这一组)顶点变换为完全不同的图元,并且还能生成比原来更多的顶点。

引例

废话不多说,我们直接先看一个几何着色器的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (points) in;
layout (line_strip, max_vertices = 2) out;

void main() {
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

在几何着色器的顶部,我们需要声明从顶点着色器输入的图元类型。这需要在in关键字前声明一个==布局修饰符==(Layout Qualifier)。这个输入布局修饰符可以从顶点着色器接收下列任何一个图元值:

  • points:绘制GL_POINTS图元时(1)。
  • lines:绘制GL_LINES或GL_LINE_STRIP时(2)
  • lines_adjacency:GL_LINES_ADJACENCY或GL_LINE_STRIP_ADJACENCY(4)
  • triangles:GL_TRIANGLES、GL_TRIANGLE_STRIP或GL_TRIANGLE_FAN(3)
  • triangles_adjacency:GL_TRIANGLES_ADJACENCY或GL_TRIANGLE_STRIP_ADJACENCY(6)

以上是能提供给glDrawArrays渲染函数的几乎所有图元了。如果我们想要将顶点绘制为GL_TRIANGLES,我们就要将输入修饰符设置为triangles。==括号内的数字==表示的是一个图元所包含的==最小顶点数==。

接下来,我们还需要指定几何着色器输出的图元类型,这需要在out关键字前面加一个布局修饰符。和输入布局修饰符一样,输出布局修饰符也可以接受几个图元值:

  • points
  • line_strip
  • triangle_strip

有了这3个输出修饰符,我们就可以使用输入图元创建几乎任意的形状了。要生成一个三角形的话,我们将输出定义为triangle_strip,并输出3个顶点。

几何着色器同时希望我们设置一个它==最大能够输出的顶点数量(==如果你超过了这个值,OpenGL将不会绘制多出的顶点),这个也可以在out关键字的布局修饰符中设置。在这个例子中,我们将输出一个line_strip,并将最大顶点数设置为2个。

如果你不知道什么是线条(Line Strip):线条连接了一组点,形成一条连续的线,它最少要由两个点来组成。在渲染函数中每多加一个点,就会在这个点与前一个点之间形成一条新的线。在下面这张图中,我们有5个顶点:

image

如果使用的是上面定义的着色器,那么这将只能输出一条线段,因为最大顶点数等于2。


为了生成更有意义的结果,我们需要某种方式来获取前一着色器阶段的输出。GLSL提供给我们一个内建(Built-in)变量,在内部看起来(可能)是这样的:

1
2
3
4
5
6
in gl_Vertex
{
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
} gl_in[];

这里,它被声明为一个==接口块==(Interface Block,我们在[上一节](https://learnopengl-cn.github.io/04 Advanced OpenGL/08 Advanced GLSL/)已经讨论过),它包含了几个很有意思的变量,其中最有趣的一个是gl_Position,它是和顶点着色器输出非常相似的一个向量。

要注意的是,它被声明为一个==数组==,因为大多数的渲染图元包含多于1个的顶点,而几何着色器的输入是一个图元的==所有顶点==。

有了之前顶点着色器阶段的顶点数据,我们就可以使用2个几何着色器函数,EmitVertex和EndPrimitive,来生成新的数据了。几何着色器希望你能够==生成并输出至少一个定义为输出的图元==。

在我们的例子中,我们需要至少生成一个线条图元。

1
2
3
4
5
6
7
8
9
void main() {
gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0);
EmitVertex();

gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
EmitVertex();

EndPrimitive();
}

每次我们调用EmitVertex时,gl_Position中的向量会被添加到图元中来。

当EndPrimitive被调用时,所有发射出的(Emitted)顶点都会合成为指定的输出渲染图元。

在一个或多个EmitVertex调用之后重复调用EndPrimitive能够生成多个图元。

在这个例子中,我们发射了两个顶点,它们从原始顶点位置平移了一段距离,之后调用了EndPrimitive,将这两个顶点合成为一个包含两个顶点的线条。

现在你(大概)了解了几何着色器的工作方式,你可能已经猜出这个几何着色器是做什么的了。它接受一个点图元作为输入,以这个点为中心,创建一条水平的线图元。如果我们渲染它,看起来会是这样的:

image

目前还并没有什么令人惊叹的效果,但考虑到这个输出是通过调用下面的渲染函数来生成的,它还是很有意思的:

1
glDrawArrays(GL_POINTS, 0, 4); // 绘制4个顶点

虽然这是一个比较简单的例子,它的确向你展示了如何能够使用几何着色器来(动态地)生成新的形状。在之后我们会利用几何着色器创建出更有意思的效果,但现在我们仍将从创建一个简单的几何着色器开始。

使用几何着色器

为了展示几何着色器的用法,我们将会渲染一个非常简单的场景,我们只会在标准化设备坐标的z平面上绘制四个点。这些点的坐标是:

1
2
3
4
5
6
float points[] = {
-0.5f, 0.5f, // 左上
0.5f, 0.5f, // 右上
0.5f, -0.5f, // 右下
-0.5f, -0.5f // 左下
};

顶点着色器只需要在z平面绘制点就可以了,所以我们将使用一个最基本顶点着色器

1
2
3
4
5
6
7
#version 330 core
layout (location = 0) in vec2 aPos;

void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
}

直接在片段着色器中硬编码,将所有的点都输出为绿色:

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

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

为点的顶点数据生成一个VAO和一个VBO,然后使用glDrawArrays进行绘制:

1
2
3
shader.use();
glBindVertexArray(VAO);
glDrawArrays(GL_POINTS, 0, 4);

结果是在黑暗的场景中有四个(很难看见的)绿点:

image

我们将会添加一个几何着色器,为场景添加活力。

出于学习目的,我们将会创建一个传递(Pass-through)几何着色器,它会接收一个点图元,并直接将它传递(Pass)到下一个着色器:

1
2
3
4
5
6
7
8
9
#version 330 core
layout (points) in;
layout (points, max_vertices = 1) out;

void main() {
gl_Position = gl_in[0].gl_Position;
EmitVertex();
EndPrimitive();
}

现在这个几何着色器应该很容易理解了,它只是将它接收到的顶点位置不作修改直接发射出去,并生成一个点图元。

和顶点与片段着色器一样,几何着色器也需要编译和链接,但这次在创建着色器时我们将会使用GL_GEOMETRY_SHADER作为着色器类型:

1
2
3
4
5
6
geometryShader = glCreateShader(GL_GEOMETRY_SHADER);
glShaderSource(geometryShader, 1, &gShaderCode, NULL);
glCompileShader(geometryShader);
...
glAttachShader(program, geometryShader);
glLinkProgram(program);

着色器编译的代码和顶点与片段着色器代码都是一样的。记得要检查编译和链接错误!

如果你现在编译并运行程序,会看到和下面类似的结果:

image

这和没使用几何着色器时是完全一样的!我承认这是有点无聊,但既然我们仍然能够绘制这些点,所以几何着色器是正常工作的,现在是时候做点更有趣的东西了!

实例:造几个房子

绘制点和线并没有那么有趣,所以我们会使用一点创造力,利用几何着色器在每个点的位置上绘制一个房子。

要实现这个,我们可以将几何着色器的输出设置为triangle_strip,并绘制三个三角形:其中两个组成一个正方形,另一个用作房顶。

OpenGL中,三角形带(Triangle Strip)是绘制三角形更高效的方式,它使用顶点更少。在第一个三角形绘制完之后,每个后续顶点将会在上一个三角形边上生成另一个三角形:每3个临近的顶点将会形成一个三角形。

如果我们一共有6个构成三角形带的顶点,那么我们会得到这些三角形:(1, 2, 3)、(2, 3, 4)、(3, 4, 5)和(4, 5, 6),共形成4个三角形。

一个三角形带至少需要3个顶点,并会生成N-2个三角形。

使用6个顶点,我们创建了6-2 = 4个三角形。下面这幅图展示了这点:

image

通过使用三角形带作为几何着色器的输出,我们可以很容易创建出需要的房子形状,只需要以正确的顺序生成3个相连的三角形就行了。

下面这幅图展示了顶点绘制的顺序,蓝点代表的是输入点:

image

变为几何着色器是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#version 330 core
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;

void build_house(vec4 position)
{
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:左下
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:顶部
EmitVertex();
EndPrimitive();
}

void main() {
build_house(gl_in[0].gl_Position);
}

这个几何着色器生成了5个顶点,每个顶点都是原始点的位置加上一个偏移量,来组成一个大的三角形带。最终的图元会被光栅化,然后片段着色器会处理整个三角形带,最终在每个绘制的点处生成一个绿色房子:

image

你可以看到,每个房子实际上是由3个三角形组成的——全部都是使用空间中一点来绘制的。这些绿房子看起来是有点无聊,所以我们会再给每个房子分配一个不同的颜色。

为了实现这个,我们需要在顶点着色器中添加一个额外的顶点属性,表示颜色信息,将它传递至几何着色器,并再次发送到片段着色器中。

下面是更新后的顶点数据:

1
2
3
4
5
6
float points[] = {
-0.5f, 0.5f, 1.0f, 0.0f, 0.0f, // 左上
0.5f, 0.5f, 0.0f, 1.0f, 0.0f, // 右上
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, // 右下
-0.5f, -0.5f, 1.0f, 1.0f, 0.0f // 左下
};

然后我们更新顶点着色器,使用一个接口块将颜色属性发送到几何着色器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;

out VS_OUT {
vec3 color;
} vs_out;

void main()
{
gl_Position = vec4(aPos.x, aPos.y, 0.0, 1.0);
vs_out.color = aColor;
}

接下来我们还需要在几何着色器中声明相同的接口块(使用一个不同的接口名):

1
2
3
in VS_OUT {
vec3 color;
} gs_in[];

因为几何着色器是作用于输入的一组顶点的,从顶点着色器发来输入数据总是会以数组的形式表示出来,即便我们现在只有一个顶点。

image

接下来我们还需要为下个片段着色器阶段声明一个输出颜色向量:

1
out vec3 fColor;

因为片段着色器只需要一个(插值的)颜色,发送多个颜色并没有什么意义。所以,fColor向量就不是一个数组,而是一个单独的向量。当发射一个顶点的时候,每个顶点将会使用最后在fColor中储存的值,来用于片段着色器的运行。

对我们的房子来说,我们只需要在第一个顶点发射之前,使用顶点着色器中的颜色填充fColor一次就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
fColor = gs_in[0].color; // gs_in[0] 因为只有一个输入顶点
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:左下
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:顶部
EmitVertex();
EndPrimitive();

所有发射出的顶点都将嵌有最后储存在fColor中的值,即顶点的颜色属性值。所有的房子都会有它们自己的颜色了:

image

仅仅是为了有趣,我们也可以假装这是冬天,将最后一个顶点的颜色设置为白色,给屋顶落上一些雪。

1
2
3
4
5
6
7
8
9
10
11
12
13
fColor = gs_in[0].color; 
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 1:左下
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 2:右下
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 3:左上
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 4:右上
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 5:顶部
fColor = vec3(1.0, 1.0, 1.0);
EmitVertex();
EndPrimitive();

最终结果看起来是这样的:

image

实例:爆破物体

尽管绘制房子非常有趣,但我们不会经常这么做。这也是为什么我们接下来要继续深入,来爆破(Explode)物体!虽然这也是一个不怎么常用的东西,但是它能向你展示几何着色器的强大之处。

当我们说爆破一个物体时,我们并不是指要将宝贵的顶点集给炸掉,我们是要将每个三角形==沿着法向量的方向移动==一小段时间。效果就是,整个物体看起来像是沿着每个三角形的法线向量爆炸一样。爆炸三角形的效果在纳米装模型上看起来像是这样的:

image

这样的几何着色器效果的一个好处就是,无论物体有多复杂,它都能够应用上去。


因为我们想要沿着三角形的法向量位移每个顶点,我们首先需要计算这个法向量。我们所要做的是计算垂直于三角形表面的向量,仅使用我们能够访问的3个顶点。

你可能还记得在[变换](https://learnopengl-cn.github.io/01 Getting started/07 Transformations/)小节中,我们使用叉乘来获取垂直于其它两个向量的一个向量。

如果我们能够获取两个平行于三角形表面的向量a和b,我们就能够对这两个向量进行叉乘来获取法向量了。下面这个几何着色器函数做的正是这个,来使用3个输入顶点坐标来获取法向量:

1
2
3
4
5
6
vec3 GetNormal()
{
vec3 a = vec3(gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
vec3 b = vec3(gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
return normalize(cross(a, b));
}

这里我们使用减法获取了两个平行于三角形表面的向量a和b。

注意,如果我们交换了cross函数中a和b的位置,我们会得到一个指向相反方向的法向量——这里的顺序很重要!


既然知道了如何计算法向量了,我们就能够创建一个explode函数了,它使用法向量和顶点位置向量作为参数。这个函数会返回一个新的向量,它是位置向量沿着法线向量进行位移之后的结果:

1
2
3
4
5
6
vec4 explode(vec4 position, vec3 normal)
{
float magnitude = 2.0;
vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magnitude;
return position + vec4(direction, 0.0);
}

函数本身应该不是非常复杂。sin函数接收一个time参数,它根据时间返回一个-1.0到1.0之间的值。因为我们不想让物体向内爆炸(Implode),我们将sin值变换到了[0, 1]的范围内。最终的结果会乘以normal向量,并且最终的direction向量会被加到位置向量上。

当使用我们的[模型加载器](https://learnopengl-cn.github.io/03 Model Loading/01 Assimp/)绘制一个模型时,爆破(Explode)效果的完整几何着色器是这样的:

  • 输入:一个三角图元
  • 输出:一个新的三角图元
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;

in VS_OUT {
vec2 texCoords;
} gs_in[];

out vec2 TexCoords;

uniform float time;

vec4 explode(vec4 position, vec3 normal) { ... }

vec3 GetNormal() { ... }

void main() {
vec3 normal = GetNormal();

gl_Position = explode(gl_in[0].gl_Position, normal);
TexCoords = gs_in[0].texCoords;
EmitVertex();
gl_Position = explode(gl_in[1].gl_Position, normal);
TexCoords = gs_in[1].texCoords;
EmitVertex();
gl_Position = explode(gl_in[2].gl_Position, normal);
TexCoords = gs_in[2].texCoords;
EmitVertex();
EndPrimitive();
}

注意我们在发射顶点之前输出了对应的纹理坐标。

而且别忘了在OpenGL代码中设置time变量:

1
shader.setFloat("time", glfwGetTime());

最终的效果是,3D模型看起来随着时间不断在爆破它的顶点,在这之后又回到正常状态。

实例:法向量可视化

在这一部分中,我们将使用几何着色器来实现一个真正有用的例子:显示任意物体的法向量。

思路是这样的:

  • 我们首先不使用几何着色器正常绘制场景。
  • 然后再次绘制场景,但这次只显示通过几何着色器生成法向量。

几何着色器接收一个三角形图元,并沿着法向量生成三条线——每个顶点一个法向量。伪代码看起来会像是这样:

1
2
3
4
shader.use();
DrawScene();
normalDisplayShader.use();
DrawScene();

这次在几何着色器中,我们会使用模型提供的顶点法线,而不是自己生成,为了适配(观察和模型矩阵的)缩放和旋转,我们在将法线变换到观察空间坐标之前,先使用法线矩阵变换一次(几何着色器接受的位置向量是观察空间坐标,所以我们应该将法向量变换到相同的空间中)。这可以在顶点着色器中完成:

几何着色器接受的位置向量是观察空间坐标:这是根据需求决定的,因为我们想在观察空间内观察法向量。所以顶点着色器中也没有乘以 projection 矩阵

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out VS_OUT {
vec3 normal;
} vs_out;

uniform mat4 view;
uniform mat4 model;

void main()
{
gl_Position = view * model * vec4(aPos, 1.0);
mat3 normalMatrix = mat3(transpose(inverse(view * model)));
vs_out.normal = normalize(vec3(vec4(normalMatrix * aNormal, 0.0)));
}

变换后的观察空间法向量会以接口块的形式传递到下个着色器阶段。


接下来,几何着色器会接收每一个顶点(包括一个位置向量和一个法向量),并在每个位置向量处绘制一个法线向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#version 330 core
layout (triangles) in;
layout (line_strip, max_vertices = 6) out;

in VS_OUT {
vec3 normal;
} gs_in[];

const float MAGNITUDE = 0.4;

uniform mat4 projection;

void GenerateLine(int index)
{
gl_Position = projection * gl_in[index].gl_Position;
EmitVertex();
gl_Position = projection * (gl_in[index].gl_Position +
vec4(gs_in[index].normal, 0.0) * MAGNITUDE);
EmitVertex();
EndPrimitive();
}

void main()
{
GenerateLine(0); // 第一个顶点法线
GenerateLine(1); // 第二个顶点法线
GenerateLine(2); // 第三个顶点法线
}

像这样的几何着色器应该很容易理解了。注意我们将法向量乘以了一个MAGNITUDE向量,来限制显示出的法向量大小(否则它们就有点大了)。


因为法线的可视化通常都是用于调试目的,我们可以使用片段着色器,将它们显示为单色的线(如果你愿意也可以是非常好看的线):

1
2
3
4
5
6
7
#version 330 core
out vec4 FragColor;

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

现在,首先使用普通着色器渲染模型,再使用特别的法线可视化着色器渲染,你将看到这样的效果:

image

尽管我们的纳米装现在看起来像是一个体毛很多而且带着隔热手套的人,它能够很有效地帮助我们判断模型的法线是否正确。你可以想象到,这样的几何着色器也经常用于给物体添加==毛发==(Fur)。

4.10 实例化

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/10%20Instancing/

引例

假设你有一个绘制了很多模型的场景,而大部分的模型包含的是同一组顶点数据,只不过进行的是不同的世界空间变换。

想象一个充满草的场景:每根草都是一个包含几个三角形的小模型。你可能会需要绘制很多根草,最终在每帧中你可能会需要渲染上千或者上万根草。因为每一根草仅仅是由几个三角形构成,渲染几乎是瞬间完成的,但上千个渲染函数调用却会极大地影响性能。

如果我们==需要渲染大量物体==时,代码看起来会像这样:

1
2
3
4
5
for(unsigned int i = 0; i < amount_of_models_to_draw; i++)
{
DoSomePreparations(); // 绑定VAO,绑定纹理,设置uniform等
glDrawArrays(GL_TRIANGLES, 0, amount_of_vertices);
}

如果像这样绘制模型的大量==实例==(Instance),你很快就会因为绘制调用过多而达到性能瓶颈。

与绘制顶点本身相比,使用glDrawArrays或glDrawElements函数告诉GPU去绘制你的顶点数据会消耗更多的性能,因为OpenGL在绘制顶点数据之前需要做很多准备工作(比如告诉GPU该从哪个缓冲读取数据,从哪寻找顶点属性,而且这些都是在相对缓慢的CPU到GPU总线(CPU to GPU Bus)上进行的)。

所以,即便渲染顶点非常快,命令GPU去渲染却未必。

如果我们能够将数据==一次性==发送给GPU,然后使用一个绘制函数让OpenGL利用这些数据==绘制多个物体==,就会更方便了。这就是==实例化==(Instancing)。

  • 实例化这项技术能够让我们使用一个渲染调用来绘制多个物体,来节省每次绘制物体时CPU -> GPU的通信,它只需要一次即可。

如果想使用实例化渲染,我们只需要将glDrawArrays和glDrawElements的渲染调用分别改为glDrawArraysInstancedglDrawElementsInstanced就可以了。

这些渲染函数的实例化版本需要一个额外的参数,叫做==实例数量==(Instance Count),它能够设置我们需要渲染的实例个数。

这样我们只需要将必须的数据发送到GPU一次,然后使用一次函数调用告诉GPU它应该如何绘制这些实例。GPU将会直接渲染这些实例,而不用不断地与CPU进行通信。


这个函数本身并没有什么用。渲染同一个物体一千次对我们并没有什么用处,每个物体都是完全相同的,而且还在同一个位置。我们只能看见一个物体!出于这个原因,GLSL在顶点着色器中嵌入了另一个内建变量,==gl_InstanceID==。

在使用实例化渲染调用时,gl_InstanceID会从0开始,在每个实例被渲染时递增1。

比如说,我们正在渲染第43个实例,那么顶点着色器中它的gl_InstanceID将会是42。因为每个实例都有唯一的ID,我们可以建立一个数组,将ID与位置值对应起来,将每个实例放置在世界的不同位置。

为了体验一下实例化绘制,我们将会在标准化设备坐标系中使用一个渲染调用,绘制100个2D四边形。我们会索引一个包含100个偏移向量的uniform数组,将偏移值加到每个实例化的四边形上。最终的结果是一个排列整齐的四边形网格:

image

每个四边形由2个三角形所组成,一共有6个顶点。

每个顶点包含一个2D的标准化设备坐标位置向量和一个颜色向量。 下面就是这个例子使用的顶点数据,为了大量填充屏幕,每个三角形都很小:

1
2
3
4
5
6
7
8
9
10
float quadVertices[] = {
// 位置 // 颜色
-0.05f, 0.05f, 1.0f, 0.0f, 0.0f,
0.05f, -0.05f, 0.0f, 1.0f, 0.0f,
-0.05f, -0.05f, 0.0f, 0.0f, 1.0f,

-0.05f, 0.05f, 1.0f, 0.0f, 0.0f,
0.05f, -0.05f, 0.0f, 1.0f, 0.0f,
0.05f, 0.05f, 0.0f, 1.0f, 1.0f
};

片段着色器会从顶点着色器接受颜色向量,并将其设置为它的颜色输出,来实现四边形的颜色:

1
2
3
4
5
6
7
8
9
#version 330 core
out vec4 FragColor;

in vec3 fColor;

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

到现在都没有什么新内容,但从顶点着色器开始就变得很有趣了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;

out vec3 fColor;

uniform vec2 offsets[100];

void main()
{
vec2 offset = offsets[gl_InstanceID];
gl_Position = vec4(aPos + offset, 0.0, 1.0);
fColor = aColor;
}

这里我们定义了一个叫做offsets的数组,它包含100个偏移向量。在顶点着色器中,我们会使用gl_InstanceID来索引offsets数组,获取每个实例的偏移向量。如果我们要实例化绘制100个四边形,仅使用这个顶点着色器我们就能得到100个位于不同位置的四边形。

当前,我们仍要设置这些偏移位置,我们会在进入渲染循环之前使用一个嵌套for循环计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
glm::vec2 translations[100];
int index = 0;
float offset = 0.1f;
for(int y = -10; y < 10; y += 2)
{
for(int x = -10; x < 10; x += 2)
{
glm::vec2 translation;
translation.x = (float)x / 10.0f + offset;
translation.y = (float)y / 10.0f + offset;
translations[index++] = translation;
}
}

这里,我们创建100个位移向量,表示10x10网格上的所有位置。除了生成translations数组之外,我们还需要将数据转移到顶点着色器的uniform数组中:

1
2
3
4
5
6
7
8
9
shader.use();
for(unsigned int i = 0; i < 100; i++)
{
stringstream ss;
string index;
ss << i;
index = ss.str();
shader.setVec2(("offsets[" + index + "]").c_str(), translations[i]);
}

在这一段代码中,我们将for循环的计数器i转换为一个string,我们可以用它来动态创建位置值的字符串,用于uniform位置值的索引。接下来,我们会对offsets uniform数组中的每一项设置对应的位移向量。


现在所有的准备工作都做完了,我们可以开始渲染四边形了。对于实例化渲染,我们使用glDrawArraysInstancedglDrawElementsInstanced。因为我们使用的不是索引缓冲,我们会调用glDrawArrays版本的函数:

1
2
glBindVertexArray(quadVAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);

glDrawArraysInstanced的参数和glDrawArrays完全一样,除了最后多了个参数用来设置需要绘制的实例数量。因为我们想要在10x10网格中显示100个四边形,我们将它设置为100.运行代码之后,你应该能得到熟悉的100个五彩的四边形。

实例化数组

虽然之前的实现在目前的情况下能够正常工作,但是如果我们要渲染远超过100个实例的时候(这其实非常普遍),我们最终会超过最大能够发送至着色器的uniform数据大小上限

它的一个代替方案是实例化数组(Instanced Array),它被定义为一个顶点属性(能够让我们储存更多的数据),仅在顶点着色器渲染一个新的实例时才会更新

使用==顶点属性==时,顶点着色器的每次运行都会让GLSL获取新一组适用于当前顶点的属性。而当我们将顶点属性定义为一个==实例化数组==时,顶点着色器就只需要对每个实例,而不是每个顶点,更新顶点属性的内容了。

这允许我们对逐顶点的数据使用普通的顶点属性,而对逐实例的数据使用实例化数组。


为了给你一个实例化数组的例子,我们将使用之前的例子,并将偏移量uniform数组设置为一个实例化数组。我们需要在顶点着色器中再添加一个顶点属性:

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aOffset;

out vec3 fColor;

void main()
{
gl_Position = vec4(aPos + aOffset, 0.0, 1.0);
fColor = aColor;
}

我们不再使用gl_InstanceID,现在不需要索引一个uniform数组就能够直接使用offset属性了。

因为实例化数组和position与color变量一样,都是顶点属性,我们还需要将它的内容存在顶点缓冲对象中,并且配置它的属性指针。我们首先将(上一部分的)translations数组存到一个新的缓冲对象中

1
2
3
4
5
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);

之后我们还需要设置它的顶点属性指针,并启用顶点属性:

1
2
3
4
5
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribDivisor(2, 1);

这段代码很有意思的地方在于最后一行,我们调用了glVertexAttribDivisor。这个函数告诉了OpenGL该什么时候更新顶点属性的内容至新一组数据。

  • 它的第一个参数是==需要的顶点属性==
  • 第二个参数是==属性除数==(Attribute Divisor)
    • 默认情况下,属性除数是0,告诉OpenGL我们需要在顶点着色器的每次迭代时更新顶点属性。
    • 将它设置为1时,我们告诉OpenGL我们希望在渲染一个新实例的时候更新顶点属性。
    • 而设置为2时,我们希望每2个实例更新一次属性,以此类推。

我们将属性除数设置为1,是在告诉OpenGL,处于位置值2的顶点属性是一个实例化数组。

如果我们现在使用glDrawArraysInstanced,再次渲染四边形,会得到以下输出:

image

这和之前的例子是完全一样的,但这次是使用实例化数组实现的,这让我们能够传递更多的数据到顶点着色器(只要内存允许)来用于实例化绘制。


为了更有趣一点,我们也可以使用gl_InstanceID,从右上到左下逐渐缩小四边形:

  • 左下Instance_ID是0,往右依次增大,往上依次增大
1
2
3
4
5
6
void main()
{
vec2 pos = aPos * (gl_InstanceID / 100.0);
gl_Position = vec4(pos + aOffset, 0.0, 1.0);
fColor = aColor;
}

结果就是,第一个四边形的实例会非常小,随着绘制实例的增加,gl_InstanceID会越来越接近100,四边形也就越来越接近原始大小。像这样将实例化数组与gl_InstanceID结合使用是完全可行的。

image

实例:小行星带

想象这样一个场景,在宇宙中有一个大的行星,它位于小行星带的中央。这样的小行星带可能包含成千上万的岩块,在很不错的显卡上也很难完成这样的渲染。

实例化渲染正是适用于这样的场景,因为所有的小行星都可以使用一个模型来表示。每个小行星可以再使用不同的变换矩阵来进行少许的变化。


为了展示实例化渲染的作用,我们首先会不使用实例化渲染,来渲染小行星绕着行星飞行的场景。

为了得到想要的效果,我们将会为每个小行星生成一个变换矩阵,用作它们的模型矩阵。

  • 变换矩阵首先将小行星==位移到小行星带中的某处==,我们还会加一个小的==随机偏移值==到这个偏移量上,让这个圆环看起来更自然一点。
  • 接下来,我们应用一个==随机的缩放==,并且以一个旋转向量为轴进行一个==随机的旋转==。

最终的变换矩阵不仅能将小行星变换到行星的周围,而且会让它看起来更自然,与其它小行星不同。最终的结果是一个布满小行星的圆环,其中每一个小行星都与众不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
unsigned int amount = 1000;
glm::mat4 *modelMatrices;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime()); // 初始化随机种子
float radius = 50.0;
float offset = 2.5f;
for(unsigned int i = 0; i < amount; i++)
{
glm::mat4 model;
// 1. 位移:分布在半径为 'radius' 的圆形上,偏移的范围是 [-offset, offset]
float angle = (float)i / (float)amount * 360.0f;
float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float x = sin(angle) * radius + displacement;
displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float y = displacement * 0.4f; // 让行星带的高度比x和z的宽度要小
displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float z = cos(angle) * radius + displacement;
model = glm::translate(model, glm::vec3(x, y, z));

// 2. 缩放:在 0.05 和 0.25f 之间缩放
float scale = (rand() % 20) / 100.0f + 0.05;
model = glm::scale(model, glm::vec3(scale));

// 3. 旋转:绕着一个(半)随机选择的旋转轴向量进行随机的旋转
float rotAngle = (rand() % 360);
model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));

// 4. 添加到矩阵的数组中
modelMatrices[i] = model;
}

这段代码看起来可能有点吓人,但我们只是将小行星的xz位置变换到了一个半径为radius的圆形上,并且在半径的基础上偏移了-offset到offset。我们让y偏移的影响更小一点,让小行星带更扁平一点。

接下来,我们应用了缩放和旋转变换,并将最终的变换矩阵储存在modelMatrices中,这个数组的大小是amount。这里,我们一共生成1000个模型矩阵,每个小行星一个。


在加载完行星和岩石模型,并编译完着色器之后,渲染的代码看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 绘制行星
shader.use();
glm::mat4 model;
model = glm::translate(model, glm::vec3(0.0f, -3.0f, 0.0f));
model = glm::scale(model, glm::vec3(4.0f, 4.0f, 4.0f));
shader.setMat4("model", model);
planet.Draw(shader);

// 绘制小行星
for(unsigned int i = 0; i < amount; i++)
{
shader.setMat4("model", modelMatrices[i]);
rock.Draw(shader);
}

我们首先绘制了行星的模型,并对它进行位移和缩放,以适应场景,接下来,我们绘制amount数量的岩石模型。在绘制每个岩石之前,我们首先需要在着色器内设置对应的模型变换矩阵。

最终的结果是一个看起来像是太空的场景,环绕着行星的是看起来很自然的小行星带:

image

这个场景每帧包含1001次渲染调用,其中1000个是岩石模型。你可以在这里找到源代码。

当我们开始增加这个数字的时候,你很快就会发现场景不再能够流畅运行了,帧数也下降很厉害。当我们将amount设置为2000的时候,场景就已经慢到移动都很困难的程度了。


现在,我们来尝试使用实例化渲染来渲染相同的场景。我们首先对顶点着色器进行一点修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in mat4 instanceMatrix;

out vec2 TexCoords;

uniform mat4 projection;
uniform mat4 view;

void main()
{
gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0);
TexCoords = aTexCoords;
}

我们不再使用模型uniform变量,改为一个mat4的顶点属性,让我们能够存储一个实例化数组的变换矩阵。

  • 然而,当我们顶点属性的类型大于vec4时,就要多进行一步处理了。顶点属性==最大允许==的数据大小==等于==一个vec4。

因为一个mat4本质上是4个vec4,我们需要为这个矩阵==预留4个顶点属性==。因为我们将它的位置值设置为3,矩阵每一列的顶点属性位置值就是3、4、5和6。

接下来,我们需要为这4个顶点属性设置属性指针,并将它们设置为实例化数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 顶点缓冲对象
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, amount * sizeof(glm::mat4), &modelMatrices[0], GL_STATIC_DRAW);

for(unsigned int i = 0; i < rock.meshes.size(); i++)
{
unsigned int VAO = rock.meshes[i].VAO;
glBindVertexArray(VAO);
// 顶点属性
GLsizei vec4Size = sizeof(glm::vec4);
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0);
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(vec4Size));
glEnableVertexAttribArray(5);
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size));
glEnableVertexAttribArray(6);
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size));

glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);

glBindVertexArray(0);
}

注意这里我们将Mesh的VAO从私有变量改为了公有变量,让我们能够访问它的顶点数组对象。这并不是最好的解决方案,只是为了配合本小节的一个简单的改动。

除此之外代码就应该很清楚了。我们告诉了OpenGL应该如何解释每个缓冲顶点属性的缓冲,并且告诉它这些顶点属性是实例化数组。


接下来,我们再次使用网格的VAO,这一次使用glDrawElementsInstanced进行绘制:

1
2
3
4
5
6
7
8
9
// 绘制小行星
instanceShader.use();
for(unsigned int i = 0; i < rock.meshes.size(); i++)
{
glBindVertexArray(rock.meshes[i].VAO);
glDrawElementsInstanced(
GL_TRIANGLES, rock.meshes[i].indices.size(), GL_UNSIGNED_INT, 0, amount
);
}

这里,我们绘制与之前相同数量amount的小行星,但是使用的是实例渲染。结果应该是非常相似的,但如果你开始增加amount变量,你就能看见实例化渲染的效果了。

没有实例化渲染的时候,我们只能流畅渲染1000到1500个小行星。而使用了实例化渲染之后,我们可以将这个值设置为100000,每个岩石模型有576个顶点,每帧加起来大概要绘制5700万个顶点,但性能却没有受到任何影响!

image

可以看到,在合适的环境下,实例化渲染能够大大增加显卡的渲染能力。正是出于这个原因,实例化渲染通常会用于渲染草、植被、粒子,以及上面这样的场景,基本上只要场景中有很多重复的形状,都能够使用实例化渲染来提高性能。

4.11 抗锯齿

https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/11%20Anti%20Aliasing/

在学习渲染的旅途中,你可能会时不时遇到模型边缘有锯齿的情况。这些==锯齿边缘==(Jagged Edges)的产生和光栅器将顶点数据转化为片段的方式有关。在下面的例子中,你可以看到,我们只是绘制了一个简单的立方体,你就能注意到它存在锯齿边缘了:

image image

这很明显不是我们想要在最终程序中所实现的效果。你能够清楚看见形成边缘的像素。这种现象被称之为==走样==(Aliasing)。

有很多种==抗锯齿==(Anti-aliasing,也被称为==反走样==)的技术能够帮助我们缓解这种现象,从而产生更平滑的边缘。

最开始我们有一种叫做超采样抗锯齿(Super Sample Anti-aliasing, SSAA)的技术,它会使用比正常分辨率更高的分辨率(即超采样)来渲染场景,当图像输出在帧缓冲中更新时,分辨率会被下采样(Downsample)至正常的分辨率。

  • 这些额外的分辨率会被用来防止锯齿边缘的产生。虽然它确实能够解决走样的问题,但是由于这样比平时要绘制更多的片段,它也会带来很大的性能开销。所以这项技术只拥有了短暂的辉煌。

然而,在这项技术的基础上也诞生了更为现代的技术,叫做多重采样抗锯齿(Multisample Anti-aliasing, MSAA)。它借鉴了SSAA背后的理念,但却以更加高效的方式实现了抗锯齿。我们在这一节中会深度讨论OpenGL中内建的MSAA技术。

多重采样

为了理解什么是==多重采样==(Multisampling),以及它是如何解决锯齿问题的,我们有必要更加深入地了解OpenGL光栅器的工作方式。

光栅器是位于==最终处理过的顶点之后==到==片段着色器之前==所经过的==所有的算法与过程的总和==。

光栅器会将一个图元的所有顶点作为输入,并将它转换为一系列的片段。顶点坐标理论上可以取任意值,但片段不行,因为它们受限于你窗口的分辨率。顶点坐标与片段之间几乎永远也不会有一对一的映射,所以光栅器必须以某种方式来决定每个顶点最终所在的片段/屏幕坐标。

image

这里我们可以看到一个屏幕像素的网格,每个像素的中心包含有一个==采样点==(Sample Point),它会被用来决定这个三角形是否遮盖了某个像素。

图中==红色的采样点==被三角形所遮盖,在每一个遮住的像素处都会==生成一个片段==。虽然三角形边缘的一些部分也遮住了某些屏幕像素,但是这些像素的采样点并没有被三角形内部所遮盖,所以它们不会受到片段着色器的影响。

你现在可能已经清楚走样的原因了。完整渲染后的三角形在屏幕上会是这样的:

image

由于屏幕像素总量的限制,有些边缘的像素能够被渲染出来,而有些则不会。结果就是我们使用了不光滑的边缘来渲染图元,导致之前讨论到的锯齿边缘。


==多重采样==所做的正是==将单一的采样点变为多个采样点==(这也是它名称的由来)。

我们不再使用像素中心的单一采样点,取而代之的是以特定图案排列的4个子采样点(Subsample)。我们将用这些子采样点来决定像素的遮盖度。当然,这也意味着颜色缓冲的大小会随着子采样点的增加而增加。

image

上图的左侧展示了正常情况下判定三角形是否遮盖的方式。在例子中的这个像素上不会运行片段着色器(所以它会保持空白)。因为它的采样点并未被三角形所覆盖。

上图的右侧展示的是实施多重采样之后的版本,每个像素包含有4个采样点。这里,只有两个采样点遮盖住了三角形。

采样点的数量可以是任意的,更多的采样点能带来更精确的遮盖率。

从这里开始多重采样就变得有趣起来了。我们知道三角形只遮盖了2个子采样点,所以下一步是决定这个像素的颜色。你的猜想可能是,我们对每个被遮盖住的子采样点运行一次片段着色器,最后将每个像素所有子采样点的颜色平均一下。

在这个例子中,我们需要在两个子采样点上对被插值的顶点数据运行两次片段着色器,并将结果的颜色储存在这些采样点中。(幸运的是)这并不是它工作的方式,因为这本质上说还是需要运行更多次的片段着色器,会显著地降低性能。


==MSAA真正的工作方式==是,无论三角形遮盖了多少个子采样点,(每个图元中)每个像素==只运行一次片段着色器==。

晦涩的解释:

片段着色器所使用的顶点数据会插值到每个像素的中心,所得到的结果颜色会被储存在每个被遮盖住的子采样点中。

当颜色缓冲的子样本被图元的所有颜色填满时,所有的这些颜色将会在每个像素内部平均化。

因为上图的4个采样点中只有2个被遮盖住了,这个像素的颜色将会是三角形颜色与其他两个采样点的颜色(在这里是无色)的平均值,最终形成一种淡蓝色。

白话解释:

根据覆盖的采样点数量进行加权平均,得到该片段的颜色

这样子做之后,颜色缓冲中所有的图元边缘将会产生一种更平滑的图形。让我们来看看前面三角形的多重采样会是什么样子:

image

这里,每个像素包含4个子采样点(不相关的采样点都没有标注),蓝色的采样点被三角形所遮盖,而灰色的则没有。

对于三角形的内部的像素,片段着色器只会运行一次,颜色输出会被存储到全部的4个子样本中。

而在三角形的边缘,并不是所有的子采样点都被遮盖,所以片段着色器的结果将只会储存到部分的子样本中。

根据被遮盖的子样本的数量,最终的像素颜色将由三角形的颜色与其它子样本中所储存的颜色来决定。


简单来说,一个像素中如果有更多的采样点被三角形遮盖,那么这个像素的颜色就会更接近于三角形的颜色。如果我们给上面的三角形填充颜色,就能得到以下的效果:

image

对于每个像素来说,越少的子采样点被三角形所覆盖,那么它受到三角形的影响就越小。三角形的不平滑边缘被稍浅的颜色所包围后,从远处观察时就会显得更加平滑了。

不仅仅是颜色值会受到多重采样的影响,深度和模板测试也能够使用多个采样点。

  • 对深度测试来说,每个顶点的深度值会在运行深度测试之前被插值到各个子样本中。
  • 对模板测试来说,我们对每个子样本,而不是每个像素,存储一个模板值。当然,这也意味着深度和模板缓冲的大小会乘以子采样点的个数。

我们到目前为止讨论的都是多重采样抗锯齿的背后原理,光栅器背后的实际逻辑比目前讨论的要复杂,但你现在应该已经可以理解多重采样抗锯齿的大体概念和逻辑了。

(译者注: 如果看到这里还是对原理似懂非懂,可以简单看看知乎上@文刀秋二对抗锯齿技术的精彩介绍)

image

OpenGL中的MSAA

如果我们想要在OpenGL中使用MSAA,我们必须要使用一个能在每个像素中存储大于1个颜色值的颜色缓冲(因为多重采样需要我们为每个采样点都储存一个颜色)。

所以,我们需要一个新的缓冲类型,来存储特定数量的多重采样样本,它叫做==多重采样缓冲(==Multisample Buffer)。

大多数的窗口系统都应该提供了一个多重采样缓冲,用以代替默认的颜色缓冲。

GLFW同样给了我们这个功能,我们所要做的只是提示(Hint) GLFW,我们希望使用一个包含N个样本的多重采样缓冲。这可以在创建窗口之前调用glfwWindowHint来完成。

1
glfwWindowHint(GLFW_SAMPLES, 4);

现在再调用glfwCreateWindow创建渲染窗口时,每个屏幕坐标就会使用一个包含4个子采样点的颜色缓冲了。GLFW会自动创建一个每像素4个子采样点的深度和样本缓冲。这也意味着所有缓冲的大小都增长了4倍。


现在我们已经向GLFW请求了多重采样缓冲,我们还需要调用glEnable并启用GL_MULTISAMPLE,来启用多重采样。

在大多数OpenGL的驱动上,多重采样都是默认启用的,所以这个调用可能会有点多余,但显式地调用一下会更保险一点。这样子不论是什么OpenGL的实现都能够正常启用多重采样了。

1
glEnable(GL_MULTISAMPLE);

只要默认的帧缓冲有了多重采样缓冲的附件,我们所要做的只是调用glEnable来启用多重采样。

因为多重采样的算法都在OpenGL驱动的光栅器中实现了,我们不需要再多做什么。如果现在再来渲染本节一开始的那个绿色的立方体,我们应该能看到更平滑的边缘:

image

离屏MSAA

由于GLFW负责了创建多重采样缓冲,启用MSAA非常简单。然而,如果我们想要使用我们自己的帧缓冲来进行离屏渲染,那么我们就必须要自己动手生成多重采样缓冲了。

有两种方式可以创建多重采样缓冲,将其作为帧缓冲的附件:纹理附件和渲染缓冲附件,这和在[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/)教程中所讨论的普通附件很相似。

多重采样纹理附件

为了创建一个支持储存多个采样点的纹理,我们使用glTexImage2DMultisample来替代glTexImage2D,它的纹理目标是GL_TEXTURE_2D_MULTISAPLE。

1
2
3
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, samples, GL_RGB, width, height, GL_TRUE);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, 0);

它的第二个参数设置的是纹理所拥有的样本个数。如果最后一个参数为GL_TRUE,图像将会对每个纹素使用相同的样本位置以及相同数量的子采样点个数。

我们使用glFramebufferTexture2D将多重采样纹理附加到帧缓冲上,但这里纹理类型使用的是GL_TEXTURE_2D_MULTISAMPLE。

1
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0);

当前绑定的帧缓冲现在就有了一个==纹理图像形式的多重采样颜色缓冲==。

多重采样渲染缓冲对象

和纹理类似,创建一个多重采样渲染缓冲对象并不难。我们所要做的只是在指定(当前绑定的)渲染缓冲的内存存储时,将glRenderbufferStorage的调用改为glRenderbufferStorageMultisample就可以了。

1
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, width, height);

函数中,渲染缓冲对象后的参数我们将设定为样本的数量,在当前的例子中是4。

渲染到多重采样帧缓冲

渲染到多重采样帧缓冲对象的过程都是自动的。只要我们在帧缓冲绑定时绘制任何东西,==光栅器就会负责所有的多重采样运算==。

我们最终会得到一个多重采样颜色缓冲以及/或深度和模板缓冲。因为==多重采样缓冲有一点特别==,==我们不能直接将它们的缓冲图像用于其他运算==,比如在着色器中对它们进行采样。

一个多重采样的图像包含比普通图像更多的信息,我们所要做的是==缩小或者还原==(Resolve)图像。

==多重采样帧缓冲的还原==通常是通过glBlitFramebuffer来完成,它能够将一个帧缓冲中的某个区域复制到另一个帧缓冲中,并且将多重采样缓冲还原。

glBlitFramebuffer会将一个用4个屏幕空间坐标所定义的源区域复制到一个同样用4个屏幕空间坐标所定义的目标区域中。

  • 你可能记得在[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/)教程中,当我们绑定到GL_FRAMEBUFFER时,我们是同时绑定了读取和绘制的帧缓冲目标。我们也可以将帧缓冲分开绑定至GL_READ_FRAMEBUFFER与GL_DRAW_FRAMEBUFFER。

glBlitFramebuffer函数会==根据这两个目标==,决定哪个是源帧缓冲,哪个是目标帧缓冲。接下来,我们可以将图像位块==传送==(Blit)到默认的帧缓冲中,将多重采样的帧缓冲传送到屏幕上。

1
2
3
glBindFramebuffer(GL_READ_FRAMEBUFFER, multisampledFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);

如果现在再来渲染这个程序,我们会得到与之前完全一样的结果:一个使用MSAA显示出来的橄榄绿色的立方体,而且锯齿边缘明显减少了:

image

但如果我们想要使用多重采样帧缓冲的纹理输出来做像是后期处理这样的事情呢?

我们不能直接在片段着色器中使用多重采样的纹理。但我们能做的是将多重采样缓冲位块传送到一个没有使用多重采样纹理附件的FBO中。然后用这个普通的颜色附件来做后期处理,从而达到我们的目的。

然而,这也意味着我们需要生成一个新的FBO,作为中介帧缓冲对象,将多重采样缓冲还原为一个能在着色器中使用的普通2D纹理。这个过程的伪代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
unsigned int msFBO = CreateFBOWithMultiSampledAttachments();
// 使用普通的纹理颜色附件创建一个新的FBO
...
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, screenTexture, 0);
...
while(!glfwWindowShouldClose(window))
{
...

glBindFramebuffer(msFBO);
ClearFrameBuffer();
DrawScene();
// 将多重采样缓冲还原到中介FBO上
glBindFramebuffer(GL_READ_FRAMEBUFFER, msFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, intermediateFBO);
glBlitFramebuffer(0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
// 现在场景是一个2D纹理缓冲,可以将这个图像用来后期处理
glBindFramebuffer(GL_FRAMEBUFFER, 0);
ClearFramebuffer();
glBindTexture(GL_TEXTURE_2D, screenTexture);
DrawPostProcessingQuad();

...
}

如果现在再实现[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/)教程中的后期处理效果,我们就能够在一个几乎没有锯齿的场景纹理上进行后期处理了。如果施加模糊的核滤镜,看起来将会是这样:

image

因为屏幕纹理又变回了一个只有单一采样点的普通纹理,像是边缘检测这样的后期处理滤镜会重新导致锯齿。为了补偿这一问题,你可以之后对纹理进行模糊处理,或者想出你自己的抗锯齿算法。

你可以看到,如果将多重采样与离屏渲染结合起来,我们需要自己负责一些额外的细节。但所有的这些细节都是值得额外的努力的,因为多重采样能够显著提升场景的视觉质量。

当然,要注意,如果使用的采样点非常多,启用多重采样会显著降低程序的性能。在本节写作时,通常采用的是4采样点的MSAA。

自定义抗锯齿算法

将一个多重采样的纹理图像不进行还原直接传入着色器也是可行的。GLSL提供了这样的选项,让我们能够对纹理图像的每个子样本进行采样,所以我们可以创建我们自己的抗锯齿算法。在大型的图形应用中通常都会这么做。

要想获取每个子样本的颜色值,你需要将纹理uniform采样器设置为sampler2DMS,而不是平常使用的sampler2D:

1
uniform sampler2DMS screenTextureMS;

使用texelFetch函数就能够获取每个子样本的颜色值了:

1
vec4 colorSample = texelFetch(screenTextureMS, TexCoords, 3);  // 第4个子样本

我们不会深入探究自定义抗锯齿技术的细节,这里仅仅是给你一点启发。

Ch5.高级光照

为节省时间,第五章开始直接使用作者给的工程跑结果

5.1 Blinn-Phong

在[光照](https://learnopengl-cn.github.io/02 Lighting/02 Basic Lighting/)小节中,我们简单地介绍了冯氏光照模型,它让我们的场景有了一定的真实感。虽然冯氏模型看起来已经很不错了,但是使用它的时候仍然存在一些细节问题,我们将在这一节里讨论它们。

冯氏光照不仅对真实光照有很好的近似,而且性能也很高。但是它的镜面反射会在一些情况下出现问题,特别是物体反光度很低时,会导致大片(粗糙的)高光区域。下面这张图展示了当反光度为1.0时地板会出现的效果:

image

可以看到,在镜面高光区域的边缘出现了一道很明显的断层。

出现这个问题的原因是观察向量和反射向量间的夹角不能大于90度。如果点积的结果为负数,镜面光分量会变为0.0。

你可能会觉得,当光线与视线夹角大于90度时你应该不会接收到任何光才对,所以这不是什么问题。

然而,这种想法仅仅只适用于漫反射分量。当考虑漫反射光的时候,如果法线和光源夹角大于90度,光源会处于被照表面的下方,这个时候光照的漫反射分量的确是为0.0。

但是,在考虑镜面高光时,我们测量的角度并不是光源与法线的夹角,而是视线与反射光线向量的夹角。看一下下面这两张图:

image

现在问题就应该很明显了。左图中是我们熟悉的冯氏光照中的反射向量,其中$\theta$角小于90度。

而右图中,视线与反射方向之间的夹角明显大于90度,这种情况下镜面光分量会变为0.0。

这在大多数情况下都不是什么问题,因为观察方向离反射方向都非常远。

然而,当物体的反光度非常小时,它产生的镜面高光半径足以让这些相反方向的光线对亮度产生足够大的影响。在这种情况下就不能忽略它们对镜面光分量的贡献了。


1977年,James F. Blinn在冯氏着色模型上加以拓展,引入了Blinn-Phong着色模型。Blinn-Phong模型与冯氏模型非常相似,但是它对镜面光模型的处理上有一些不同,让我们能够解决之前提到的问题。

Blinn-Phong模型不再依赖于反射向量,而是采用了所谓的半程向量(Halfway Vector),即光线与视线夹角一半方向上的一个单位向量。当半程向量与法线向量越接近时,镜面光分量就越大。

image

当视线正好与(现在不需要的)反射向量对齐时,半程向量就会与法线完美契合。所以当观察者视线越接近于原本反射光线的方向时,镜面高光就会越强。

现在,不论观察者向哪个方向看,==半程向量与表面法线之间的夹角都不会超过90度==(除非光源在表面以下)。

它产生的效果会与冯氏光照有些许不同,但是大部分情况下看起来会更自然一点,特别是低高光的区域。Blinn-Phong着色模型正是早期固定渲染管线时代时OpenGL所采用的光照模型。

获取半程向量的方法很简单,只需要将光线的方向向量和观察向量加到一起,并将结果正规化(Normalize)就可以了:

image

翻译成GLSL代码如下:

1
2
3
vec3 lightDir   = normalize(lightPos - FragPos);
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);

接下来,镜面光分量的实际计算只不过是对表面法线和半程向量进行一次约束点乘(Clamped Dot Product),让点乘结果不为负,从而获取它们之间夹角的余弦值,之后我们对这个值取反光度次方:

1
2
float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;

除此之外Blinn-Phong模型就没什么好说的了,==Blinn-Phong==与==冯氏模型==唯一的区别就是,==Blinn-Phong测量的是法线与半程向量之间的夹角==,而==冯氏模型测量的是观察方向与反射向量间的夹角==。

在引入半程向量之后,我们现在应该就不会再看到冯氏光照中高光断层的情况了。下面两个图片展示的是两种方法在镜面光分量为0.5时的对比:

image

除此之外,冯氏模型与Blinn-Phong模型也有一些细微的差别:半程向量与表面法线的夹角通常会==小于==观察与反射向量的夹角。

所以,如果你想获得和冯氏着色类似的效果,就必须在使用Blinn-Phong模型时将镜面反光度设置更高一点。通常我们会选择冯氏着色时反光度分量的2到4倍。

下面是冯氏着色反光度为8.0,Blinn-Phong着色反光度为32.0时的一个对比:

image

你可以看到,Blinn-Phong的镜面光分量会比冯氏模型更锐利一些。为了得到与冯氏模型类似的结果,你可能会需要不断进行一些微调,但Blinn-Phong模型通常会产出更真实的结果。

这里,我们使用了一个简单的片段着色器,让我们能够在冯氏反射与Blinn-Phong反射间进行切换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void main()
{
[...]
float spec = 0.0;
if(blinn)
{
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 16.0);
}
else
{
vec3 reflectDir = reflect(-lightDir, normal);
spec = pow(max(dot(viewDir, reflectDir), 0.0), 8.0);
}
...
}

5.2 Gamma校正

https://learnopengl-cn.github.io/05%20Advanced%20Lighting/02%20Gamma%20Correction/

当我们计算出场景中所有像素的最终颜色以后,我们就必须把它们显示在监视器上。

过去,大多数监视器是阴极射线管显示器(CRT)。这些监视器有一个物理特性就是两倍的输入电压产生的不是两倍的亮度。输入电压产生约为输入电压的2.2次幂的亮度,这叫做监视器Gamma。

Gamma也叫灰度系数,每种显示设备都有自己的Gamma值,都不相同。

有一个公式:设备输出亮度 = 电压的Gamma次幂

任何设备Gamma基本上都不会等于1,等于1是一种理想的线性状态,这种理想状态是:如果电压和亮度都是在0到1的区间,那么多少电压就等于多少亮度。

对于CRT,Gamma通常为2.2,因而,输出亮度 = 输入电压的2.2次幂

你可以从本节第二张图中看到Gamma2.2实际显示出来的总会比预期暗,相反Gamma0.45就会比理想预期亮,如果你讲Gamma0.45叠加到Gamma2.2的显示设备上,便会对偏暗的显示效果做到校正,这个简单的思路就是本节的核心

人类所感知的亮度恰好和CRT所显示出来相似的指数关系非常匹配。为了更好的理解所有含义,请看下面的图片:

image

第一行是人眼所感知到的正常的灰阶,亮度要增加一倍(比如从0.1到0.2)你才会感觉比原来变亮了一倍

译注:我们在看颜色值从0到1(从黑到白)的过程中,亮度要增加一倍,我们才会感受到明显的颜色变化(变亮一倍)。

打个比方:颜色值从0.1到0.2,我们会感受到一倍的颜色变化,而从0.4到0.8我们才能感受到相同程度(变亮一倍)的颜色变化。如果还是不理解,可以参考知乎的答案

然而,当我们谈论光的物理亮度,比如光源发射光子的数量的时候,底部(第二行)的灰阶显示出的才是物理世界真实的亮度。如底部的灰阶显示,亮度加倍时返回的也是真实的物理亮度

译注:这里亮度是指光子数量和正相关的亮度,即物理亮度,前面讨论的是人的感知亮度;

物理亮度和感知亮度的区别在于,物理亮度基于光子数量,感知亮度基于人的感觉,比如第二个灰阶里亮度0.1的光子数量是0.2的二分之一

但是由于这与我们的眼睛感知亮度不完全一致(对比较暗的颜色变化更敏感),所以它看起来有差异。


因为==人眼==看到颜色的亮度==更倾向于顶部的灰阶==,监视器使用的也是一种指数关系(电压的2.2次幂),所以==物理亮度==通过==监视器==能够被==映射到顶部的非线性亮度==;因此看起来效果不错

译注:CRT亮度是是电压的2.2次幂而人眼相当于2次幂,因此CRT这个缺陷正好能满足人的需要。

监视器的这个非线性映射的确可以让亮度在我们眼中看起来更好,但当渲染图像时,会产生一个问题:我们在应用中配置的亮度和颜色是基于监视器所看到的,这样所有的配置实际上是非线性的亮度/颜色配置。请看下图:

image

点线代表线性颜色/亮度值(译注:这表示的是理想状态,Gamma为1),实线代表监视器显示的颜色(偏暗,符合人眼视觉感知)。

如果我们把一个点线线性的颜色翻一倍,结果就是这个值的两倍。

比如,光的颜色向量$\overline{L}=(0.5,0.0,0.0)$代表的是暗红色。如果我们在线性空间中把它翻倍,就会变成$(1.0,0.0,0.0)$,就像你在图中看到的那样。

然而,由于我们定义的颜色仍然需要输出的监视器上,监视器上显示的实际颜色就会是$(0.218,0.0,0.0)$。在这儿问题就出现了:当我们将理想中直线上的那个暗红色翻一倍时,在监视器上实际上亮度翻了4.5倍以上!(0.218*4.5=0.981)

分析:颜色(0.5, 0, 0, 0)实际看到时只有(0.218, 0, 0, 0)。现在要让它翻倍成为纯红色(1, 0, 0, 0),相当于翻4.5倍


直到现在,我们还一直假设我们所有的工作都是在线性空间中进行的(译注:Gamma为1),但最终还是要把所有的颜色输出到监视器上,所以我们配置的所有颜色和光照变量从物理角度来看都是不正确的,在我们的监视器上很少能够正确地显示。

出于这个原因,我们(以及艺术家)通常将光照值设置得比本来更亮一些(由于监视器会将其亮度显示的更暗一些),如果不是这样,在线性空间里计算出来的光照就会不正确。

同时,还要==记住==,监视器所显示出来的图像和线性图像的==最小亮度是相同的==,它们==最大的亮度也是相同的==;只是==中间亮度部分会被压暗==。

因为所有中间亮度都是线性空间计算出来的(译注:计算的时候假设Gamma为1),经过监视器显以后,实际上都会不正确。当使用更高级的光照算法时,这个问题会变得越来越明显,你可以看看下图:

image

Gamma校正原理

gamma校正:最终输出颜色应用(1/2.2)次幂,再通过监视器后会映射到线性空间,这意味着之前的光照计算能够在线性空间进行。

==Gamma校正==(Gamma Correction)的思路是在==最终的颜色输出上应用监视器Gamma的倒数==。

回头看前面的Gamma曲线图,你会有一个短划线,它是监视器Gamma曲线的翻转曲线。我们在颜色显示到监视器的时候把每个颜色输出都加上这个翻转的Gamma曲线,这样应用了监视器Gamma以后最终的颜色将会变为线性的。我们所得到的中间色调就会更亮,所以虽然监视器使它们变暗,但是我们又将其平衡回来了。(解释:将最终的颜色应用1/2.2次幂,提亮)

我们来看另一个例子。还是那个暗红色$(0.5,0.0,0.0)$。在将颜色显示到监视器之前,我们先对颜色应用Gamma校正曲线。线性的颜色显示在监视器上相当于降低了2.2次幂的亮度,所以倒数就是1/2.2次幂。

Gamma校正后的暗红色就会成为$(0.5,0.0,0.0)^{1/2.2}$=$(0.5,0.0,0.0)^{0.45}$=$(0.73,0.0,0.0)$

校正后的颜色接着被发送给监视器,最终显示出来的颜色是$(0.73,0.0,0.0)^{2.2}$=(0.5,0.0,0.0)。

你会发现使用了Gamma校正,监视器最终会显示出我们在应用中设置的那种线性的颜色。

2.2通常是是大多数显示设备的大概平均gamma值。基于gamma2.2的颜色空间叫做sRGB颜色空间。每个监视器的gamma曲线都有所不同,但是gamma2.2在大多数监视器上表现都不错。出于这个原因,游戏经常都会为玩家提供改变游戏gamma设置的选项,以适应每个监视器

(译注:现在Gamma2.2相当于一个标准,后文中你会看到。但现在你可能会问,前面不是说Gamma2.2看起来不是正好适合人眼么,为何还需要校正。这是因为你在程序中设置的颜色,比如光照都是基于线性Gamma,即Gamma1,所以你理想中的亮度和实际表达出的不一样,如果要表达出你理想中的亮度就要对这个光照进行校正)。


有两种在你的场景中应用gamma校正的方式:

使用OpenGL内建的sRGB帧缓冲。 自己在像素着色器中进行gamma校正。 第一个选项也许是最简单的方式,但是我们也会丧失一些控制权。

开启GL_FRAMEBUFFER_SRGB,可以告诉OpenGL每个后续的绘制命令里,在颜色储存到颜色缓冲之前先校正sRGB颜色。sRGB这个颜色空间大致对应于gamma2.2,它也是家用设备的一个标准。开启GL_FRAMEBUFFER_SRGB以后,每次像素着色器运行后续帧缓冲,OpenGL将自动执行gamma校正,包括默认帧缓冲。

开启GL_FRAMEBUFFER_SRGB简单的调用glEnable就行:

1
glEnable(GL_FRAMEBUFFER_SRGB);

自此,你渲染的图像就被进行gamma校正处理,你不需要做任何事情硬件就帮你处理了。

有时候,你应该记得这个建议:

gamma校正将把线性颜色空间转变为非线性空间,所以在最后一步进行gamma校正是极其重要的。如果你在最后输出之前就进行gamma校正,所有的后续操作都是在操作不正确的颜色值。

例如,如果你使用多个帧缓冲,你可能打算让两个帧缓冲之间传递的中间结果仍然保持线性空间颜色,只是给发送给监视器的最后的那个帧缓冲应用gamma校正。


第二个方法稍微复杂点,但同时也是我们对gamma操作有完全的控制权。我们在每个相关像素着色器运行的最后应用gamma校正,所以在发送到帧缓冲前,颜色就被校正了。

1
2
3
4
5
6
7
8
void main()
{
// do super fancy lighting
[...]
// apply gamma correction
float gamma = 2.2;
fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

最后一行代码,将fragColor的每个颜色元素应用有一个1.0/gamma的幂运算,校正像素着色器的颜色输出。

这个方法有个问题就是为了保持一致,你必须在像素着色器里加上这个gamma校正,所以如果你有很多像素着色器,它们可能分别用于不同物体,那么你就必须在每个着色器里都加上gamma校正了。一个更简单的方案是在你的渲染循环中==引入后处理阶段==,在后处理四边形上应用gamma校正,这样你==只要做一次==就好了。

sRGB纹理

sRGB纹理:gamma校正后的纹理。使用时需要pow(2.2)才能转换到线性空间。

因为监视器总是在sRGB空间中显示应用了gamma的颜色,无论什么时候当你在计算机上绘制、编辑或者画出一个图片的时候,你所选的颜色都是根据你在监视器上看到的那种。这==实际意味着==所有==你创建或编辑的图片并不是在线性空间==,而是在sRGB空间中(译注:sRGB空间定义的gamma接近于2.2),假如在你的屏幕上对暗红色翻一倍,便是根据你所感知到的亮度进行的,并不等于将红色元素加倍。

结果就是纹理编辑者,所创建的所有纹理都是在sRGB空间中的纹理,所以如果我们在渲染应用中使用这些纹理,我们必须考虑到这点。在我们应用gamma校正之前,这不是个问题,因为纹理在sRGB空间创建和展示,同样我们还是在sRGB空间中使用,从而不必gamma校正纹理显示也没问题。

然而,现在我们是把所有东西都放在线性空间中展示的,纹理颜色就会变坏,如下图展示的那样:

image

纹理图像(右图)实在太亮了,发生这种情况是因为,它们实际上进行了两次gamma校正!

  • 当我们基于监视器上看到的情况创建一个图像,我们就已经对颜色值进行了gamma校正,所以再次显示在监视器上就没错。由于我们在渲染中又进行了一次gamma校正,图片就实在太亮了。

为了修复这个问题,我们得确保纹理制作者是在线性空间中进行创作的。但是,由于大多数纹理制作者并不知道什么是gamma校正,并且在sRGB空间中进行创作更简单,这也许不是一个好办法。


sRGB标准是图片颜色值固定按照(1/2.2,即0.45)次幂的颜色变化(sRGB存储的颜色值实际在编码伽马的空间当中)

image

另一个解决方案是重校,或把这些==sRGB纹理在==进行任何颜色值的计算前==变回线性空间==。我们可以这样做:

1
2
float gamma = 2.2;
vec3 diffuseColor = pow(texture(diffuse, texCoords).rgb, vec3(gamma));

为每个sRGB空间的纹理做这件事非常烦人。幸好,OpenGL给我们提供了另一个方案来解决我们的麻烦,这就是GL_SRGBGL_SRGB_ALPHA内部纹理格式。

如果我们在OpenGL中创建了一个纹理,把它指定为以上两种sRGB纹理格式其中之一,OpenGL将自动把颜色校正到线性空间中,这样我们所使用的所有颜色值都是在线性空间中的了。我们可以这样把一个纹理指定为一个sRGB纹理:

1
glTexImage2D(GL_TEXTURE_2D, 0, GL_SRGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, image);

如果你还打算在你的纹理中==引入alpha元素==,==必须==将纹理的内部格式指定为GL_SRGB_ALPHA。


因为不是所有纹理都是在sRGB空间中的所以当你把纹理指定为sRGB纹理时要格外小心。

比如diffuse纹理,这种为物体上色的纹理几乎都是在sRGB空间中的。而为了获取光照参数的纹理,像specular贴图和法线贴图几乎都在线性空间中,所以如果你把它们也配置为sRGB纹理的话,光照就坏掉了。指定sRGB纹理时要当心。

将diffuse纹理定义为sRGB纹理之后,你将获得你所期望的视觉输出,但这次每个物体都会只进行一次gamma校正。

衰减

在使用了gamma校正之后,另一个不同之处是光照衰减(Attenuation)。真实的物理世界中,光照的衰减和光源的距离的平方成反比。

1
2
//如果不进行gamma校正,这样写衰减更强烈。建议最后进行gamma校正时写该公式
float attenuation = 1.0 / (distance * distance);

然而,当我们使用这个衰减公式的时候,衰减效果总是过于强烈,光只能照亮一小圈,看起来并不真实。出于这个原因,我们使用在基本光照教程中所讨论的那种衰减方程,它给了我们更大的控制权,此外我们还可以使用双曲线函数:

1
2
//如果不进行gamma校正,这样写自带gamma校正。建议最后不进行gamma校正时写该公式
float attenuation = 1.0 / distance;

双曲线比使用二次函数变体在不用gamma校正的时候看起来更真实,不过但我们开启gamma校正以后线性衰减看起来太弱了,符合物理的二次函数突然出现了更好的效果。下图显示了其中的不同:

image

这种差异产生的原因是,光的衰减方程改变了亮度值,而且屏幕上显示出来的也不是线性空间,在监视器上效果最好的衰减方程,并不是符合物理的。想想平方衰减方程,如果我们使用这个方程,而且不进行gamma校正,显示在监视器上的衰减方程实际上将变成$(1.0/distance2)^{2.2}$。

若不进行gamma校正,将产生更强烈的衰减。

这也解释了为什么双曲线不用gamma校正时看起来更真实,因为它实际变成了$(1.0/distance)^{2.2}$=$1.0/distance^{2.2}$。这和物理公式是很相似的。


总而言之,==gamma校正使你可以在线性空间中进行操作==。因为线性空间更符合物理世界,大多数物理公式现在都可以获得较好效果,比如真实的光的衰减。你的光照越真实,使用gamma校正获得漂亮的效果就越容易。这也正是为什么当引进gamma校正时,建议只去调整光照参数的原因。

小结(看这个就够了)

  • 片段着色器输出的颜色是经过gamma校正 pow(1/2.2) ,尽管显示器存储的是gamma校正后的颜色,但==最终==呈现的颜色是(人眼观察的颜色)位于==线性空间==中。(显示器特性决定了它自动进行反向gamma校正)
  • sRGB纹理默认经过gamma校正,位于gamma编码空间 pow(1/2.2)。因此在片段着色器采样后,需要反向gamma校正 pow(2.2) 映射到线性空间,再进行光照计算。(反向gamma校正有时也叫gamma补偿)
  • Unity中,如果是Gamma空间,对sRGB纹理内部不会处理,输出也不会处理;如果是Linear空间,对sRGB纹理会自动进行==反向gamma校正== pow(2.2),且输出会自动进行gamma校正 pow(1/2.2)
image

参考ChatGPT

image

参考:https://zhuanlan.zhihu.com/p/492870542

5.3.1 阴影映射

阴影是光线被阻挡的结果;当一个光源的光线由于其他物体的阻挡不能够达到一个物体的表面的时候,那么这个物体就在阴影中了。阴影能够使场景看起来真实得多,并且可以让观察者获得物体之间的空间位置关系。.

场景和物体的深度感因此能够得到极大提升,下图展示了有阴影和没有阴影的情况下的不同:

image

你可以看到,有阴影的时候你能更容易地区分出物体之间的位置关系,例如,当使用阴影的时候浮在地板上的立方体的事实更加清晰。

阴影还是比较不好实现的,因为当前实时渲染领域还没找到一种完美的阴影算法。目前有几种近似阴影技术,但它们都有自己的弱点和不足,这点我们必须要考虑到。

视频游戏中较多使用的一种技术是阴影贴图(shadow mapping),效果不错,而且相对容易实现。阴影贴图并不难以理解,性能也不会太低,而且非常容易扩展成更高级的算法(比如 Omnidirectional Shadow MapsCascaded Shadow Maps)。

阴影映射原理

阴影映射(Shadow Mapping)背后的思路非常简单:我们以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的一定是在阴影之中了。

假设有一个地板,在光源和它之间有一个大盒子。由于光源处向光线方向看去,可以看到这个盒子,但看不到地板的一部分,这部分就应该在阴影中了。

image

这里的所有==蓝线==代表==光源可以看到的fragment==。==黑线==代表==被遮挡的fragment==:它们应该渲染为带阴影的。

如果我们绘制一条从光源出发,到达最右边盒子上的一个片段上的线段或射线,那么射线将先击中悬浮的盒子,随后才会到达最右侧的盒子。结果就是悬浮的盒子被照亮,而最右侧的盒子将处于阴影之中。

我们希望得到射线第一次击中的那个物体,然后用这个最近点和射线上其他点进行对比。我们希望得到射线第一次击中的那个物体,然后用这个最近点和射线上其他点进行对比。然后我们将测试一下看看射线上的其他点是否比最近点更远,如果是的话,这个点就在阴影中。

对从光源发出的射线上的成千上万个点进行遍历是个极端消耗性能的举措,实时渲染上基本不可取。我们可以采取相似举措,不用投射出光的射线。我们所使用的是非常熟悉的东西:深度缓冲。


你可能记得在深度测试教程中,在深度缓冲里的一个值是摄像机视角下,对应于一个片段的一个0到1之间的深度值。如果我们从光源的透视图来渲染场景,并把深度值的结果储存到纹理中会怎样?

通过这种方式,我们就能对光源的透视图所见的最近的深度值进行采样。最终,深度值就会显示从光源的透视图下见到的第一个片段了。我们管储存在纹理中的所有这些深度值,叫做==深度贴图==(depth map)或==阴影贴图==。

image

左侧的图片展示了一个定向光源(所有光线都是平行的)在立方体下的表面投射的阴影。通过采样储存到深度贴图中的深度值,我们就能找到最近点,用以决定片段是否在阴影中。

我们使用一个来自光源的视图(观察)和投影矩阵来渲染场景就能创建一个深度贴图。这个投影和视图矩阵结合在一起成为一个T变换,它可以将任何三维位置转变到光源的可见坐标空间。

定向光并没有位置,因为它被规定为无穷远。然而,为了实现阴影贴图,我们得从一个光的透视图渲染场景,这样就得在光的方向的某一点上渲染场景。

原理举例:

在右边的图中我们显示出同样的平行光和观察者。我们渲染一个点$\overline{P}$处的片段,需要决定它是否在阴影中。我们先得使用T把$\overline{P}$变换到光源的坐标空间里。既然点$\overline{P}$是从光的透视图中看到的,它的z坐标就对应于它的深度,例子中这个值是0.9。

使用点$\overline{P}$在光源的坐标空间的坐标,我们可以索引深度贴图,来获得从光的视角中最近的可见深度,结果是点$\overline{C}$,最近的深度是0.4。因为索引深度贴图的结果是一个小于点$\overline{P}$的深度,我们可以断定$\overline{P}$被挡住了,它在阴影中了。

阴影映射由两个步骤组成:

  • 首先,我们渲染深度贴图
  • 然后我们像往常一样渲染场景,使用生成的深度贴图来计算片段是否在阴影之中。

听起来有点复杂,但随着我们一步一步地讲解这个技术,就能理解了。

深度贴图

第一步我们需要生成一张深度贴图(Depth Map)。深度贴图是从光的透视图里渲染的深度纹理,用它计算阴影。因为我们需要将场景的渲染结果储存到一个纹理中,我们将再次需要帧缓冲。

首先,我们要为渲染的深度贴图创建一个帧缓冲对象:

1
2
GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);

然后,创建一个2D纹理,提供给帧缓冲的深度缓冲使用:

1
2
3
4
5
6
7
8
9
10
11
const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;

GLuint depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

生成深度贴图不太复杂。因为我们只关心深度值,我们要把纹理格式指定为GL_DEPTH_COMPONENT。我们还要把纹理的高宽设置为1024:这是深度贴图的分辨率。

我们把生成的深度纹理作为帧缓冲的深度缓冲:

1
2
3
4
5
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0); //附加深度缓冲类型的附件
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

我们需要的只是在从光的透视图下渲染场景的时候深度信息,所以颜色缓冲没有用。然而,不包含颜色缓冲的帧缓冲对象是不完整的,所以我们需要显式告诉OpenGL我们不适用任何颜色数据进行渲染。我们通过将调用glDrawBuffer和glReadBuffer把读和绘制缓冲设置为GL_NONE来做这件事。

image image image

完整流程概览

合理配置将深度值渲染到纹理的帧缓冲后,我们就可以开始第一步了:生成深度贴图。

两个步骤的完整的渲染阶段,看起来有点像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 首选渲染深度贴图
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. 像往常一样渲染场景,但这次使用深度贴图
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();

这段代码隐去了一些细节,但它表达了阴影映射的基本思路。这里一定要记得调用glViewport。

因为阴影贴图经常和我们原来渲染的场景(通常是窗口分辨率)有着不同的分辨率,我们需要改变视口(viewport)的参数以适应阴影贴图的尺寸。如果我们忘了更新视口参数,最后的深度贴图要么太小要么就不完整。

光源空间的变换

前面那段代码中一个不清楚的函数是ConfigureShaderAndMatrices

  • 在第二个步骤中,和往常一样,它是用来为每个物体设置合适的投影和观察矩阵,以及相关的模型矩阵。

  • 然而,第一个步骤中,我们从光的位置的视野下使用了不同的投影和视图矩阵来渲染的场景。

因为我们使用的是一个所有光线都平行的定向光。出于这个原因,我们将为光源使用正交投影矩阵,透视图将没有任何变形:

1
2
GLfloat near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);

因为投影矩阵间接决定可视区域的范围,以及哪些东西不会被裁切,你需要保证投影视锥(frustum)的大小,以包含打算在深度贴图中包含的物体。当物体和片段不在深度贴图中时,它们就不会产生阴影。

为了创建一个视图矩阵来变换每个物体,把它们变换到从光源视角可见的空间中,我们将使用glm::lookAt函数;这次从光源的位置看向场景中央。

1
glm::mat4 lightView = glm::lookAt(glm::vec(-2.0f, 4.0f, -1.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

二者相结合为我们提供了一个光空间的变换矩阵,它将每个世界空间坐标变换到光源处所见到的那个空间;这正是我们渲染深度贴图所需要的。

1
glm::mat4 lightSpaceMatrix = lightProjection * lightView;

这个lightSpaceMatrix正是前面我们称为TT的那个变换矩阵。有了lightSpaceMatrix只要给shader提供光空间的投影和视图矩阵,我们就能像往常那样渲染场景了。

然而,我们只关心深度值,并非所有片段计算都在我们的着色器中进行。为了提升性能,我们将使用一个与之不同但更为简单的着色器来渲染出深度贴图。

渲染至深度贴图

当我们以光的透视图进行场景渲染的时候,我们会用一个比较简单的着色器,这个着色器除了把顶点变换到光空间以外,不会做得更多了。这个简单的着色器叫做simpleDepthShader,就是使用下面的这个着色器:

1
2
3
4
5
6
7
8
9
10
#version 330 core
layout (location = 0) in vec3 position;

uniform mat4 lightSpaceMatrix;
uniform mat4 model;

void main()
{
gl_Position = lightSpaceMatrix * model * vec4(position, 1.0f);
}

这个顶点着色器将一个单独模型的一个顶点,使用lightSpaceMatrix变换到光空间中。

由于我们没有颜色缓冲,最后的片段不需要任何处理,所以我们可以简单地使用一个空片段着色器:

1
2
3
4
5
6
#version 330 core

void main()
{
// gl_FragDepth = gl_FragCoord.z;
}

这个空片段着色器什么也不干,运行完后,深度缓冲会被更新。

我们可以取消那行的注释,来显式设置深度,但是这个(指注释掉那行之后)是更有效率的,因为底层无论如何都会默认去设置深度缓冲。

渲染深度缓冲现在成了:

1
2
3
4
5
6
7
8
simpleDepthShader.Use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));

glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

这里的RenderScene函数的参数是一个着色器程序(shader program),它调用所有相关的绘制函数,并在需要的地方设置相应的模型矩阵。

最后,在光的透视图视角下,很完美地用每个可见片段的最近深度填充了深度缓冲。通过将这个纹理投射到一个2D四边形上(和我们在帧缓冲一节做的后处理过程类似),就能在屏幕上显示出来,我们会获得这样的东西:

image

将深度贴图渲染到四边形上的片段着色器:

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 color;
in vec2 TexCoords;

uniform sampler2D depthMap;

void main()
{
float depthValue = texture(depthMap, TexCoords).r;
color = vec4(vec3(depthValue), 1.0);
}

要注意的是当用透视投影矩阵取代正交投影矩阵来显示深度时,有一些轻微的改动,因为使用透视投影时,深度是非线性的。本节教程的最后,我们会讨论这些不同之处。

渲染阴影

正确地生成深度贴图以后我们就可以开始生成阴影了。这段代码在片段着色器中执行,用来检验一个片段是否在阴影之中,不过我们在顶点着色器中进行光空间的变换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;

out vec2 TexCoords;

out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform mat4 lightSpaceMatrix;

void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}

这儿的新的地方是FragPosLightSpace这个输出向量。我们用同一个lightSpaceMatrix,把==世界空间顶点位置==转换为==光空间==。顶点着色器传递一个普通的经变换的世界空间顶点位置vs_out.FragPos和一个光空间的vs_out.FragPosLightSpace给片段着色器。


片段着色器使用Blinn-Phong光照模型渲染场景。我们接着计算出一个shadow值,==当fragment在阴影中时是1.0,在阴影外是0.0==。然后,diffuse和specular颜色会乘以这个阴影元素。由于阴影不会是全黑的(由于散射),我们把ambient分量从乘法中剔除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

float ShadowCalculation(vec4 fragPosLightSpace)
{
[...]
}

void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(1.0);
// Ambient
vec3 ambient = 0.15 * color;
// Diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// Specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// 计算阴影
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;

FragColor = vec4(lighting, 1.0f);
}

片段着色器大部分是从高级光照教程中复制过来,只不过加上了个阴影计算。我们声明一个shadowCalculation函数,用它计算阴影。片段着色器的最后,我们我们把diffuse和specular乘以(1-阴影元素),这表示这个片段有多大成分不在阴影中。

这个片段着色器还需要两个==额外输入==,一个是==光空间的片段位置==和==第一个渲染阶段得到的深度贴图==。


首先要检查一个片段是否在阴影中,把==光空间片段位置==转换为==裁切空间的标准化设备坐标==。

当我们在顶点着色器输出一个裁切空间顶点位置到gl_Position时,OpenGL==自动==进行一个透视除法,将裁切空间坐标的范围-w到w转为-1到1,这要将x、y、z元素除以向量的w元素来实现。

由于裁切空间的FragPosLightSpace并不会通过gl_Position传到片段着色器里,我们必须自己做透视除法:

1
2
3
4
5
6
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 执行透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
[...]
}

返回了片段在光空间的-1到1的范围。

当使用正交投影矩阵,顶点w元素仍保持不变,所以这一步实际上毫无意义。可是,当使用透视投影的时候就是必须的了,所以为了保证在两种投影矩阵下都有效就得留着这行。

因为来自深度贴图的深度在0到1的范围,我们也打算使用projCoords从深度贴图中去采样,所以我们将NDC坐标变换为0到1的范围。

译者注:这里的意思是,上面的projCoords的xyz分量都是[-1,1](下面会指出这对于远平面之类的点才成立),而为了和深度贴图的深度相比较,z分量需要变换到[0,1];为了作为从深度贴图中采样的坐标,xy分量也需要变换到[0,1]。所以整个projCoords向量都需要变换到[0,1]范围。

1
projCoords = projCoords * 0.5 + 0.5;

有了这些投影坐标,我们就能从深度贴图中采样得到0到1的结果,从第一个渲染阶段的projCoords坐标直接对应于变换过的NDC坐标。我们将得到光的位置视野下最近的深度:

1
float closestDepth = texture(shadowMap, projCoords.xy).r;

为了得到片段的当前深度,我们简单获取投影向量的z坐标,它等于来自光的透视视角的片段的深度。

1
float currentDepth = projCoords.z;

实际的对比就是简单检查currentDepth是否高于closetDepth,如果是,那么片段就在阴影中。

1
float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

完整的shadowCalculation函数是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 执行透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 变换到[0,1]的范围
projCoords = projCoords * 0.5 + 0.5;
// 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 取得当前片段在光源视角下的深度
float currentDepth = projCoords.z;
// 检查当前片段是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;

return shadow;
}

激活这个着色器,绑定合适的纹理,激活第二个渲染阶段默认的投影以及视图矩阵,结果如下图所示:

image

改进阴影贴图

我们试图让阴影映射工作,但是你也看到了,阴影映射还是有点不真实,我们修复它才能获得更好的效果,这是下面的部分所关注的焦点。

阴影失真

个人理解:斜着看过去,深度图存在精度问题,实际深度和深度图的深度之间容易产生误差。

  • 片段和光源角度越倾斜,一个片段周围片段的深度差距就会越大,但是深度图在远处的精度是不够高的,生成深度图时周围的片段的深度值可能会计算成同一个,导致更远一点的片段采样时d,被误算成有遮挡,被阴影覆盖。
  • 解决办法:
    • 法①==使用bias==:读出片段实际深度值,在和深度图比较时根据倾斜程度变小一些。片段越倾斜光源的,变小多一点,越垂直光源的变小少一点。(推荐做法)
    • 法②==使用正面剔除渲染阴影图==(下一小节:悬浮 会介绍)
  • 这段解释文档中写得不好,图就很抽象,但解决方案还是可以参考的。

前面的图片中明显有不对的地方。放大看会发现明显的线条样式:

image

我们可以看到地板四边形渲染出很大一块交替黑线。这种阴影贴图的不真实感叫做**阴影失真(Shadow Acne)**,下图解释了成因:

image

因为阴影贴图受限于分辨率,在距离光源比较远的情况下,多个片段可能采样到深度贴图的同一个值。该图像显示了地板,其中每个黄色倾斜面板代表深度图的单个纹素。如您所见,多个片段对同一深度样本进行采样。

虽然很多时候没问题,但是当光源以一个角度朝向表面的时候就会出问题,这种情况下深度贴图也是从一个角度下进行渲染的。多个片段就会从同一个斜坡的深度纹理像素中采样,有些在地板上面,有些在地板下面;这样我们所得到的阴影就有了差异。因为这个,有些片段被认为是在阴影之中,有些不在,由此产生了图片中的条纹样式。

我们可以用一个叫做阴影偏移(shadow bias)的技巧来解决这个问题,我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。

image

使用了偏移量后,所有==采样点都获得了比表面深度更小的深度值==,这样整个表面就正确地被照亮,没有任何阴影。我们可以这样实现这个偏移:

1
2
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

一个0.005的偏移就能帮到很大的忙,但是有些表面坡度很大,仍然会产生阴影失真。有一个更加可靠的办法能够根据表面朝向光线的角度更改偏移量 – 使用点乘:

1
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);

这里我们有一个偏移量的最大值0.05,和一个最小值0.005,它们是基于表面法线和光照方向的。这样像地板这样的表面几乎与光源垂直,得到的偏移就很小,而比如立方体的侧面这种表面得到的偏移就更大。

下图展示了同一个场景,但使用了阴影偏移,效果的确更好:

image

选用正确的偏移数值,在不同的场景中需要一些像这样的轻微调校,但大多情况下,实际上就是增加偏移量直到所有失真都被移除的问题。

悬浮

==注意==:阴影偏移和正面剔除渲染阴影图技术没有什么关系,两者独立,并不是说后者是对前者的完善。两者的区别只是出发点不同。

  • 阴影偏移(推荐使用):通过减小比较的片段深度(不是真的减小)来解决阴影失真。
    • 缺点:可能导致悬浮。
  • 正面剔除渲染阴影图:使用背面渲染阴影图,天然的和正面片段深度拉开距离,从而解决阴影失真。
    • 缺点:只能对封闭物体有效,可能存在漏光等其他问题。
  • 参考:https://www.zhihu.com/question/321779117/answer/2869189537

使用阴影偏移的一个缺点是你对物体的实际深度应用了平移。偏移有可能足够大,以至于可以看出阴影相对实际物体位置的偏移,你可以从下图看到这个现象(这是一个夸张的偏移值):

image

地板点的深度值在应用偏移后,比深度图上的深度值还要小,导致本该阴影内却没有。

  • 地板点那条箭头的起点本该和重合的,为了方便展示就分开画了,右边两条线是平行的哈。

这个阴影失真叫做悬浮(Peter Panning),因为物体看起来轻轻悬浮在表面之上(译注Peter Pan就是童话彼得潘,而panning有平移、悬浮之意,而且彼得潘是个会飞的男孩…)。

我们可以使用一个叫技巧解决大部分的Peter panning问题:当==渲染深度贴图时候使用正面剔除==(front face culling)(你也许记得在面剔除教程中OpenGL默认是背面剔除)。我们要告诉OpenGL我们要剔除正面。

因为我们只需要深度贴图的深度值,对于实体物体无论我们用它们的正面还是背面都没问题。使用背面深度不会有错误,因为阴影在物体内部有错误我们也看不见。

image

为了修复peter游移,我们要进行正面剔除,先必须开启GL_CULL_FACE

1
2
3
glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // 不要忘记设回原先的culling face

这十分有效地解决了peter panning的问题,但==只对内部不会对外开口的实体物体有效==。

我们的场景中,在立方体上工作的很好,但在地板上无效,因为正面剔除完全移除了地板。地面是一个单独的平面,不会被完全剔除。如果有人打算使用这个技巧解决peter panning必须考虑到只有剔除物体的正面才有意义。

另一个要考虑到的地方是接近阴影的物体仍然会出现不正确的效果。必须考虑到何时使用正面剔除对物体才有意义。不过使用普通的偏移值通常就能避免peter panning。

采样过多

无论你喜不喜欢还有一个视觉差异,就是==光的视锥不可见的区域一律被认为是处于阴影中==,不管它真的处于阴影之中。出现这个状况是因为超出光的视锥的投影坐标比1.0大,这样采样的深度纹理就会超出他默认的0到1的范围。根据纹理环绕方式,我们将会得到不正确的深度结果,它不是基于真实的来自光源的深度值。

image

你可以在图中看到,光照有一个区域,超出该区域就成为了阴影;这个区域实际上代表着深度贴图的大小,这个贴图投影到了地板上。发生这种情况的原因是我们之前将深度贴图的环绕方式设置成了GL_REPEAT。

我们宁可让所有超出深度贴图的坐标的深度范围是1.0,这样超出的坐标将永远不在阴影之中。我们可以储存一个边框颜色,然后把深度贴图的纹理环绕选项设置为GL_CLAMP_TO_BORDER:

1
2
3
4
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

现在如果我们采样深度贴图0到1坐标范围以外的区域,纹理函数总会返回一个1.0的深度值,阴影值为0.0。结果看起来会更真实:

image

仍有一部分是黑暗区域。那里的坐标超出了光的正交视锥的远平面。你可以看到这片黑色区域总是出现在光源视锥的极远处。

当一个点比光的远平面还要远时,它的投影坐标的z坐标大于1.0。这种情况下,GL_CLAMP_TO_BORDER环绕方式不起作用,因为我们把坐标的z元素和深度贴图的值进行了对比;它总是为大于1.0的z返回true。

解决这个问题也很简单,只要投影向量的z坐标大于1.0,我们就把shadow的值强制设为0.0:

1
2
3
4
5
6
7
8
float ShadowCalculation(vec4 fragPosLightSpace)
{
[...]
if(projCoords.z > 1.0)
shadow = 0.0;

return shadow;
}

检查远平面,并将深度贴图限制为一个手工指定的边界颜色,就能解决深度贴图采样超出的问题,我们最终会得到下面我们所追求的效果:

image

这些结果意味着,只有在深度贴图范围以内的被投影的fragment坐标才有阴影,所以任何超出范围的都将会没有阴影。由于在游戏中通常这只发生在远处,就会比我们之前的那个明显的黑色区域效果更真实。

PCF 百分比更邻近过滤

percentage-closer filtering

阴影现在已经附着到场景中了,不过这仍不是我们想要的。如果你放大看阴影,阴影映射对分辨率的依赖很快变得很明显。

image

因为深度贴图有一个固定的分辨率,多个片段对应于一个纹理像素。结果就是多个片段会从深度贴图的同一个深度值进行采样,这几个片段便得到的是同一个阴影,这就会产生锯齿边。

你可以通过==增加深度贴图的分辨率==的方式来降低锯齿块,也可以尝试==尽可能的让光的视锥接近场景==。

另一个(并不完整的)解决方案叫做==PCF(percentage-closer filtering)==,这是一种多个不同过滤方式的组合,它产生柔和阴影,使它们出现更少的锯齿块和硬边。

==核心思想==是从==深度贴图中多次采样==,每一次采样的纹理坐标都稍有不同。每个独立的样本可能在也可能不再阴影中。所有的次生结果接着结合在一起,进行==平均化==,我们就得到了柔和阴影。

一个简单的PCF的实现是简单的从纹理像素四周对深度贴图采样,然后把结果平均起来:

1
2
3
4
5
6
7
8
9
10
11
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;

这个textureSize返回一个给定采样器纹理的0级mipmap的vec2类型的宽和高。用1除以它返回一个单独纹理像素的大小,我们用以对纹理坐标进行偏移,确保每个新样本,来自不同的深度值。

这里我们采样得到9个值,它们在投影坐标的x和y值的周围,为阴影阻挡进行测试,并最终通过样本的总数目将结果平均化。

使用更多的样本,更改texelSize变量,你就可以增加阴影的柔和程度。下面你可以看到应用了PCF的阴影:

image

从稍微远一点的距离看去,阴影效果好多了,也不那么生硬了。如果你放大,仍会看到阴影贴图分辨率的不真实感,但通常对于大多数应用来说效果已经很好了。

实际上PCF还有更多的内容,以及很多技术要点需要考虑以提升柔和阴影的效果,但处于本章内容长度考虑,我们将留在以后讨论。

正交 vs 投影

在渲染深度贴图的时候,正交(Orthographic)和投影(Projection)矩阵之间有所不同。正交投影矩阵并不会将场景用透视图进行变形,所有视线/光线都是平行的,这使它对于定向光来说是个很好的投影矩阵。

然而透视投影矩阵,会将所有顶点根据透视关系进行变形,结果因此而不同。下图展示了两种投影方式所产生的不同阴影区域:

image

透视投影对于光源来说更合理,不像定向光,它是有自己的位置的。==透视投影==因此更经常用在==点光源和聚光灯==上,而==正交投影==经常用在==定向光==上。

另一个细微差别是,透视投影矩阵,将深度缓冲视觉化经常会得到一个几乎全白的结果。发生这个是因为透视投影下,深度变成了非线性的深度值,它的大多数可辨范围都位于近平面附近。

为了可以像使用正交投影一样合适地观察深度值,你必须先将非线性深度值转变为线性的,如我们在深度测试教程中已经讨论过的那样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#version 330 core
out vec4 color;
in vec2 TexCoords;

uniform sampler2D depthMap;
uniform float near_plane;
uniform float far_plane;

float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // Back to NDC
return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}

void main()
{
float depthValue = texture(depthMap, TexCoords).r;
color = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // perspective
// color = vec4(vec3(depthValue), 1.0); // orthographic
}

这个深度值与我们见到的用正交投影的很相似。需要注意的是,这个只适用于调试;正交或投影矩阵的深度检查仍然保持原样,因为相关的深度并没有改变。

附加资源

5.3.2 点光源阴影

上个教程我们学到了如何使用阴影映射技术创建动态阴影。效果不错,但它==只适合定向光==,因为阴影只是在单一定向光源下生成的。所以它也叫定向阴影映射,深度(阴影)贴图生成自定向光的视角。

本节我们的焦点是在各种方向生成动态阴影。这个技术可以适用于点光源,生成所有方向上的阴影。

这个技术叫做点光阴影,过去的名字是==万向阴影贴图==(omnidirectional shadow maps)技术。


本节代码基于前面的阴影映射教程,所以如果你对传统阴影映射不熟悉,还是建议先读一读阴影映射教程。

算法和定向阴影映射差不多:我们从光的透视图生成一个深度贴图,基于当前fragment位置来对深度贴图采样,然后用储存的深度值和每个fragment进行对比,看看它是否在阴影中。

==定向阴影映射==和==万向阴影映射==的==主要不同==在于==深度贴图的使用==上。

对于深度贴图,我们需要从一个点光源的所有渲染场景,普通2D深度贴图不能工作;如果我们使用立方体贴图会怎样?因为立方体贴图可以储存6个面的环境数据,它1可以将整个场景渲染到立方体贴图的每个面上,把它们当作点光源四周的深度值来采样。

image

生成后的深度立方体贴图被传递到光照像素着色器,它会用一个方向向量来采样立方体贴图,从而得到当前的fragment的深度(从光的透视图)。大部分复杂的事情已经在阴影映射教程中讨论过了。算法只是在深度立方体贴图生成上稍微复杂一点。

生成深度立方体贴图

为创建一个光周围的深度值的立方体贴图,我们必须渲染场景6次:每次一个面。

显然渲染场景6次需要6个不同的视图矩阵,每次把一个不同的立方体贴图面附加到帧缓冲对象上。这看起来是这样的:

1
2
3
4
5
6
7
for(int i = 0; i < 6; i++)
{
GLuint face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i;
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, face, depthCubemap, 0);
BindViewMatrix(lightViewMatrices[i]);
RenderScene();
}

这会很耗费性能因为一个深度贴图下需要进行很多渲染调用。

这个教程中我们将转而使用另外的一个小技巧来做这件事,==几何着色器==允许我们==使用一次渲染过程==来建立深度立方体贴图。


首先,我们需要创建一个立方体贴图:

1
2
GLuint depthCubemap;
glGenTextures(1, &depthCubemap);

然后生成立方体贴图的每个面,将它们作为2D深度值纹理图像:

1
2
3
4
5
const GLuint SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (GLuint i = 0; i < 6; ++i)
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT,
SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);

不要忘记设置合适的纹理参数:

1
2
3
4
5
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

正常情况下,我们把立方体贴图纹理的一个面附加到帧缓冲对象上,渲染场景6次,每次将帧缓冲的深度缓冲目标改成不同立方体贴图面。

由于我们将使用一个几何着色器,它允许我们把所有面在一个过程渲染,我们可以使用glFramebufferTexture直接把立方体贴图附加成帧缓冲的深度附件:

1
2
3
4
5
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

还要记得调用glDrawBuffer和glReadBuffer:当生成一个深度立方体贴图时我们只关心深度值,所以我们必须显式告诉OpenGL这个帧缓冲对象不会渲染到一个颜色缓冲里。

完整流程概览

万向阴影贴图有两个渲染阶段:首先我们生成深度贴图,然后我们正常使用深度贴图渲染,在场景中创建阴影。帧缓冲对象和立方体贴图的处理看起是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. first render to depth cubemap
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();

这个过程和默认的阴影映射一样,尽管这次我们渲染和使用的是一个立方体贴图深度纹理,而不是2D深度纹理。在我们实际开始从光的视角的所有方向渲染场景之前,我们先得计算出合适的变换矩阵。

光空间变换

设置了帧缓冲和立方体贴图,我们需要一些方法来讲场景的所有几何体变换到6个光的方向中相应的光空间。与阴影映射教程类似,我们将需要一个光空间的变换矩阵T,但是这次是每个面都有一个。

每个光空间的变换矩阵包含了投影和视图矩阵。对于投影矩阵来说,我们将使用一个==透视投影矩阵==;光源代表一个空间中的点,所以透视投影矩阵更有意义。每个光空间变换矩阵使用同样的投影矩阵:

1
2
3
4
GLfloat aspect = (GLfloat)SHADOW_WIDTH/(GLfloat)SHADOW_HEIGHT;
GLfloat near = 1.0f;
GLfloat far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far);

非常重要的一点是,这里glm::perspective的视野参数,设置为90度。90度我们才能保证视野足够大到可以合适地填满立方体贴图的一个面,立方体贴图的所有面都能与其他面在边缘对齐。

因为投影矩阵在每个方向上并不会改变,我们可以在6个变换矩阵中重复使用。

我们要为每个方向提供一个不同的视图矩阵。用glm::lookAt创建6个观察方向,每个都按顺序注视着立方体贴图的的一个方向:右、左、上、下、前、后:

1
2
3
4
5
6
7
8
9
10
11
12
13
std::vector<glm::mat4> shadowTransforms;
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(1.0,0.0,0.0), glm::vec3(0.0,-1.0,0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(-1.0,0.0,0.0), glm::vec3(0.0,-1.0,0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(0.0,1.0,0.0), glm::vec3(0.0,0.0,1.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(0.0,-1.0,0.0), glm::vec3(0.0,0.0,-1.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(0.0,0.0,1.0), glm::vec3(0.0,-1.0,0.0));
shadowTransforms.push_back(shadowProj *
glm::lookAt(lightPos, lightPos + glm::vec3(0.0,0.0,-1.0), glm::vec3(0.0,-1.0,0.0));

个人:从cameraUp看,感觉把深度图像沿y翻转了

这里我们创建了6个视图矩阵,把它们乘以投影矩阵,来得到6个不同的光空间变换矩阵。glm::lookAt的target参数是它注视的立方体贴图的面的一个方向。

这些变换矩阵发送到着色器渲染到立方体贴图里。

深度着色器

为了把值渲染到深度立方体贴图,我们将需要3个着色器:顶点和像素着色器,以及一个它们之间的几何着色器。

==几何着色器==是负责==将所有世界空间的顶点变换到6个不同的光空间的着色器==。因此顶点着色器简单地将顶点变换到世界空间,然后直接发送到几何着色器:

1
2
3
4
5
6
7
8
9
#version 330 core
layout (location = 0) in vec3 position;

uniform mat4 model;

void main()
{
gl_Position = model * vec4(position, 1.0);
}

紧接着几何着色器以3个三角形的顶点作为输入,它还有一个光空间变换矩阵的uniform数组。几何着色器接下来会负责将顶点变换到光空间;这里它开始变得有趣了。

几何着色器有一个内建变量叫做gl_Layer,它指定发散出基本图形送到立方体贴图的哪个面。

  • 当不管它时,几何着色器就会像往常一样把它的基本图形发送到输送管道的下一阶段
  • 但当我们更新这个变量就能控制每个基本图形将渲染到立方体贴图的哪一个面。

当然这只有当我们有了一个附加到激活的帧缓冲的立方体贴图纹理才有效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices=18) out;

uniform mat4 shadowMatrices[6];

out vec4 FragPos; // FragPos from GS (output per emitvertex)

void main()
{
for(int face = 0; face < 6; ++face)
{
gl_Layer = face; // built-in variable that specifies to which face we render.
for(int i = 0; i < 3; ++i) // for each triangle's vertices
{
FragPos = gl_in[i].gl_Position;
gl_Position = shadowMatrices[face] * FragPos;
EmitVertex();
}
EndPrimitive();
}
}

几何着色器相对简单。我们输入一个三角形,输出总共6个三角形(6*3顶点,所以总共18个顶点)。

理解:任意输入一个三角形,会生成该三角形位置下的立方体贴图(1个面->6个面)

  • 类比定向阴影映射,想象模型上的一个片段,变换到该光源空间并写深度的过程,就不难理解了。这里只不过使用了不同的view矩阵。一个片段只会对立方体贴图的一个面有影响。
image

在main函数中,我们遍历立方体贴图的6个面,我们每个面指定为一个输出面,把这个面的interger(整数)存到gl_Layer。然后,我们通过把面的光空间变换矩阵乘以FragPos,将每个世界空间顶点变换到相关的光空间,生成每个三角形。

注意,我们还要将最后的FragPos变量发送给像素着色器,我们需要计算一个深度值。


上个教程,我们使用的是一个空的像素着色器,让OpenGL配置深度贴图的深度值。这次我们将计算自己的深度,这个深度就是每个fragment位置和光源位置之间的线性距离。计算自己的深度值使得之后的阴影计算更加直观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
in vec4 FragPos;

uniform vec3 lightPos;
uniform float far_plane;

void main()
{
// get distance between fragment and light source
float lightDistance = length(FragPos.xyz - lightPos);

// map to [0;1] range by dividing by far_plane
lightDistance = lightDistance / far_plane;

// write this as modified depth
gl_FragDepth = lightDistance;
}

像素着色器将来自几何着色器的FragPos、光的位置向量和视锥的远平面值作为输入。这里我们把fragment和光源之间的距离,映射到0到1的范围,把它写入为fragment的深度值。

使用这些着色器渲染场景,立方体贴图附加的帧缓冲对象激活以后,你会得到一个完全填充的深度立方体贴图,以便于进行第二阶段的阴影计算。

使用万向阴影贴图

所有事情都做好了,是时候来应用万向阴影(Omnidirectional Shadow)了。这个过程和定向阴影映射教程相似,尽管这次我们绑定的深度贴图是一个立方体贴图,而不是2D纹理,并且将光的投影的远平面发送给了着色器。

1
2
3
4
5
6
7
8
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.Use();
// ... send uniforms to shader (including light's far_plane value)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// ... bind other textures
RenderScene();

这里的renderScene函数在一个大立方体房间中渲染一些立方体,它们散落在大立方体各处,光源在场景中央。


顶点着色器和像素着色器和原来的阴影映射着色器大部分都一样:不同之处是在光空间中像素着色器不再需要一个fragment位置,现在我们可以==使用一个方向向量采样深度值==。

疑惑:这个方向向量采样立方体贴图很符合直觉,但似乎没有理由,之前没有说明吧?

  • 先记住,采样用世界空间片段到光源的方向向量。
  • 猜测解释:对点使用线性变换,那么两个坐标点的差所构成的向量方向是一样的,所以方向向量是空间无关的。

因为这个顶点着色器不再需要将他的位置向量变换到光空间,所以我们可以去掉FragPosLightSpace变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;

out vec2 TexCoords;

out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
}

片段着色器的Blinn-Phong光照代码和我们之前阴影相乘的结尾部分一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
} fs_in;

uniform sampler2D diffuseTexture;
uniform samplerCube depthMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

uniform float far_plane;

float ShadowCalculation(vec3 fragPos)
{
[...]
}

void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(0.3);
// Ambient
vec3 ambient = 0.3 * color;
// Diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// Specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// Calculate shadow
float shadow = ShadowCalculation(fs_in.FragPos);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;

FragColor = vec4(lighting, 1.0f);
}

有一些细微的不同:光照代码一样,但我们现在有了一个uniform变量samplerCube。shadowCalculation函数用fragment的位置作为它的参数,取代了光空间的fragment位置。

我们现在还要引入光的视锥的远平面值,后面我们会需要它。

像素着色器的最后,我们计算出阴影元素,当fragment在阴影中时它是1.0,不在阴影中时是0.0。我们使用计算出来的阴影元素去影响光照的diffuse和specular元素。


在ShadowCalculation函数中有很多不同之处,现在是==从立方体贴图中进行采样==,不再使用2D纹理了。我们来一步一步的讨论一下的它的内容。

我们需要做的第一件事是获取立方体贴图的深度。你可能已经从教程的立方体贴图部分想到,我们已经将深度储存为fragment和光位置之间的距离了;我们这里采用相似的处理方式:

1
2
3
4
5
float ShadowCalculation(vec3 fragPos)
{
vec3 fragToLight = fragPos - lightPos;
float closestDepth = texture(depthMap, fragToLight).r;
}

在这里,我们得到了fragment的位置与光的位置之间的不同的向量,使用这个向量作为一个方向向量去对立方体贴图进行采样。==方向向量不需要是单位向量==,所以无需对它进行标准化(猜测采样函数会自动标准化)。最后的closestDepth是光源和它最接近的可见fragment之间的标准化的深度值。

closestDepth值现在在0到1的范围内了,所以我们先将其转换回0到far_plane的范围,这需要把他乘以far_plane:

1
closestDepth *= far_plane;

准确来说不会取到0,因为写深度时没有减去近平面的值,立方体贴图的实际范围应该是 [near/far, 1] 之间。

乘以远平面后映射到 [near, far] 之间。

下一步我们获取当前fragment和光源之间的深度值,我们可以简单的使用fragToLight的长度来获取它,这取决于我们如何计算立方体贴图中的深度值:

1
float currentDepth = length(fragToLight);

返回的是和closestDepth范围相同的深度值。

现在我们可以将两个深度值对比一下,看看哪一个更接近,以此决定当前的fragment是否在阴影当中。我们还要包含一个阴影偏移,所以才能避免阴影失真,这在前面教程中已经讨论过了。

1
2
float bias = 0.05; 
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

完整的ShadowCalculation现在变成了这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float ShadowCalculation(vec3 fragPos)
{
// Get vector between fragment position and light position
vec3 fragToLight = fragPos - lightPos;
// Use the light to fragment vector to sample from the depth map
float closestDepth = texture(depthMap, fragToLight).r;
// It is currently in linear range between [0,1]. Re-transform back to original value
closestDepth *= far_plane;
// Now get current linear depth as the length between the fragment and light position
float currentDepth = length(fragToLight);
// Now test for shadows
float bias = 0.05;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;

return shadow;
}

有了这些着色器,我们已经能得到非常好的阴影效果了,这次从一个点光源所有周围方向上都有阴影。有一个位于场景中心的点光源,看起来会像这样:

image

显示立方体贴图深度缓冲

如果你想我一样第一次并没有做对,那么就要进行调试排错,将深度贴图显示出来以检查其是否正确。因为我们不再用2D深度贴图纹理,深度贴图的显示不会那么显而易见。

一个简单的把深度缓冲显示出来的技巧是,在ShadowCalculation函数中计算标准化的closestDepth变量,把变量显示为:

1
FragColor = vec4(vec3(closestDepth / far_plane), 1.0);

结果是一个灰度场景,每个颜色代表着场景的线性深度值:

image

你可能也注意到了带阴影部分在墙外。如果看起来和这个差不多,你就知道深度立方体贴图生成的没错。否则你可能做错了什么,也许是closestDepth仍然还在0到far_plane的范围。

PCF 百分比更邻近过滤

由于万向阴影贴图基于传统阴影映射的原则,它便也继承了由解析度产生的非真实感。如果你放大就会看到锯齿边了。PCF或称Percentage-closer filtering允许我们通过对fragment位置周围过滤多个样本,并对结果平均化。

如果我们用和前面教程同样的那个简单的PCF过滤器,并加入第三个维度,就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float shadow = 0.0;
float bias = 0.05;
float samples = 4.0;
float offset = 0.1;
for(float x = -offset; x < offset; x += offset / (samples * 0.5))
{
for(float y = -offset; y < offset; y += offset / (samples * 0.5))
{
for(float z = -offset; z < offset; z += offset / (samples * 0.5))
{
float closestDepth = texture(depthMap, fragToLight + vec3(x, y, z)).r;
closestDepth *= far_plane; // Undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
}
}
shadow /= (samples * samples * samples);

这段代码和我们传统的阴影映射没有多少不同。这里我们根据样本的数量动态计算了纹理偏移量,我们在三个轴向采样三次,最后对子样本进行平均化。

现在阴影看起来更加柔和平滑了,由此得到更加真实的效果:

image

然而,samples设置为4.0,每个fragment我们会得到总共64个样本,这太多了!


大多数这些采样都是多余的,与其在原始方向向量附近处采样,不如==在采样方向向量的垂直方向进行采样更有意义==。

可是,没有(简单的)方式能够指出哪一个子方向是多余的,这就难了。

有个技巧可以使用,用一个偏移量方向数组,它们差不多都是分开的,每一个指向完全不同的方向,剔除彼此接近的那些子方向。下面就是一个有着20个偏移方向的数组:

1
2
3
4
5
6
7
8
vec3 sampleOffsetDirections[20] = vec3[]
(
vec3( 1, 1, 1), vec3( 1, -1, 1), vec3(-1, -1, 1), vec3(-1, 1, 1),
vec3( 1, 1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1, 1, -1),
vec3( 1, 1, 0), vec3( 1, -1, 0), vec3(-1, -1, 0), vec3(-1, 1, 0),
vec3( 1, 0, 1), vec3(-1, 0, 1), vec3( 1, 0, -1), vec3(-1, 0, -1),
vec3( 0, 1, 1), vec3( 0, -1, 1), vec3( 0, -1, -1), vec3( 0, 1, -1)
);

然后我们把PCF算法与从sampleOffsetDirections得到的样本数量进行适配,使用它们从立方体贴图里采样。这么做的好处是与之前的PCF算法相比,我们需要的样本数量变少了。

1
2
3
4
5
6
7
8
9
10
11
12
13
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
for(int i = 0; i < samples; ++i)
{
float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
closestDepth *= far_plane; // Undo mapping [0;1]
if(currentDepth - bias > closestDepth)
shadow += 1.0;
}
shadow /= float(samples);

这里我们把一个偏移量添加到指定的diskRadius中,它在fragToLight方向向量周围从立方体贴图里采样。


另一个在这里可以应用的有意思的技巧是,我们可以基于观察者里一个fragment的距离来改变diskRadius;这样我们就能根据观察者的距离来增加偏移半径了,当距离更远的时候阴影更柔和,更近了就更锐利。

1
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;

PCF算法的结果如果没有变得更好,也是非常不错的,这是柔和的阴影效果:

image

我还要提醒一下使用几何着色器来生成深度贴图不会一定比每个面渲染场景6次更快。使用几何着色器有它自己的性能局限,在第一个阶段使用它可能获得更好的性能表现。这取决于环境的类型,以及特定的显卡驱动等等,所以如果你很关心性能,就要确保对两种方法有大致了解,然后选择对你场景来说更高效的那个。

我个人还是喜欢使用几何着色器来进行阴影映射,原因很简单,因为它们使用起来更简单。

附加资源

5.4 法线贴图

我们的场景中已经充满了多边形物体,其中每个都可能由成百上千平坦的三角形组成。我们以向三角形上附加纹理的方式来增加额外细节,提升真实感,隐藏多边形几何体是由无数三角形组成的事实。

纹理确有助益,然而当你近看它们时,这个事实便隐藏不住了。现实中的物体表面并非是平坦的,而是表现出无数(凹凸不平的)细节。

例如,砖块的表面。砖块的表面非常粗糙,显然不是完全平坦的:它包含着接缝处水泥凹痕,以及非常多的细小的空洞。如果我们在一个有光的场景中看这样一个砖块的表面,问题就出来了。

下图中我们可以看到砖块纹理应用到了平坦的表面,并被一个点光源照亮。

image

光照并没有呈现出任何裂痕和孔洞,完全忽略了砖块之间凹进去的线条;表面看起来完全就是平的。

我们可以使用specular贴图根据深度或其他细节阻止部分表面被照的更亮,以此部分地解决问题,但这并不是一个好方案。

我们需要的是某种可以告知光照系统给所有有关物体表面类似深度这样的细节的方式。


如果我们以光的视角来看这个问题:是什么使表面被视为完全平坦的表面来照亮?答案会是==表面的法线向量==。

以光照算法的视角考虑的话,只有一件事决定物体的形状,这就是垂直于它的法线向量。砖块表面只有一个法线向量,表面完全根据这个法线向量被以一致的方式照亮。

如果每个fragment都是用自己的不同的法线会怎样?这样我们就可以根据表面细微的细节对法线向量进行改变;这样就会获得一种表面看起来要复杂得多的幻觉:

image

每个fragment使用了自己的法线,我们就可以让光照相信一个表面由很多微小的(垂直于法线向量的)平面所组成,物体表面的细节将会得到极大提升。

这种每个fragment使用各自的法线,替代一个面上所有fragment使用同一个法线的技术叫做法线贴图(normal mapping)或凹凸贴图(bump mapping)。应用到砖墙上,效果像这样:

image

你可以看到细节获得了极大提升,开销却不大。因为我们只需要改变每个fragment的法线向量,并不需要改变所有光照公式。现在我们是为每个fragment传递一个法线,不再使用插值表面法线。这样光照使表面拥有了自己的细节。

法线贴图

为使法线贴图工作,我们需要为每个fragment提供一个法线。像diffuse贴图和specular贴图一样,我们可以使用一个2D纹理来储存法线数据。

  • 2D纹理不仅可以储存颜色和光照数据,还可以储存法线向量。这样我们可以从2D纹理中采样得到特定纹理的法线向量。

由于法线向量是个几何工具,而纹理通常只用于储存颜色信息,用纹理储存法线向量不是非常直接。如果你想一想,就会知道纹理中的颜色向量用r、g、b元素代表一个3D向量。类似的我们也可以将法线向量的x、y、z元素储存到纹理中,代替颜色的r、g、b元素。

法线向量的范围在-1到1之间,所以我们先要将其映射到0到1的范围:

1
vec3 rgb_normal = normal * 0.5 + 0.5; // 从 [-1,1] 转换至 [0,1]

将法线向量变换为像这样的RGB颜色元素,我们就能把根据表面的形状的fragment的法线保存在2D纹理中。教程开头展示的那个砖块的例子的法线贴图如下所示:

image

这会是一种偏蓝色调的纹理(你在网上找到的几乎所有法线贴图都是这样的)。这是因为所有法线的指向都偏向z轴(0, 0, 1)这是一种偏蓝的颜色。

法线向量从z轴方向也向其他方向轻微偏移,颜色也就发生了轻微变化,这样看起来便有了一种深度。

  • 例如,你可以看到在每个砖块的顶部,颜色倾向于偏绿,这是因为砖块的顶部的法线偏向于指向正y轴方向(0, 1, 0),这样它就是绿色的了。

在一个简单的朝向正z轴的平面上,我们可以用这个diffuse纹理这个法线贴图来渲染前面部分的图片。

要注意的是这个链接里的法线贴图和上面展示的那个不一样。原因是OpenGL读取的纹理的y(或V)坐标和纹理通常被创建的方式相反。链接里的法线贴图的y(或绿色)元素是相反的(你可以看到绿色现在在下边)

如果你没考虑这个,光照就不正确了(译注:如果你现在不再使用SOIL了,那就不要用链接里的那个法线贴图,这个问题是SOIL载入纹理上下颠倒所致,它也会把法线在y方向上颠倒)。

加载纹理,把它们绑定到合适的纹理单元,然后使用下面的改变了的像素着色器来渲染一个平面:

1
2
3
4
5
6
7
8
9
10
11
12
uniform sampler2D normalMap;  

void main()
{
// 从法线贴图范围[0,1]获取法线
normal = texture(normalMap, fs_in.TexCoords).rgb;
// 将法线向量转换为范围[-1,1]
normal = normalize(normal * 2.0 - 1.0);

[...]
// 像往常那样处理光照
}

这里我们将被采样的法线颜色从0到1重新映射回-1到1,便能将RGB颜色重新处理成法线,然后使用采样出的法线向量应用于光照的计算。在例子中我们使用的是Blinn-Phong着色器。

通过慢慢随着时间慢慢移动光源,你就能明白法线贴图是什么意思了。运行这个例子你就能得到本教程开始的那个效果:

image

然而有个问题限制了刚才讲的那种法线贴图的使用。我们使用的那个法线贴图里面的所有法线向量都是指向正z方向的。上面的例子能用,是因为那个平面的表面法线也是指向正z方向的。可是,如果我们在表面法线指向正y方向的平面上使用同一个法线贴图会发生什么?

image

光照看起来完全不对!发生这种情况是平面的表面法线现在指向了y,而采样得到的法线仍然指向的是z。

结果就是光照仍然认为表面法线和之前朝向正z方向时一样;这样光照就不对了。下面的图片展示了这个表面上采样的法线的近似情况:

image

你可以看到所有法线都指向z方向,它们本该朝着表面法线指向y方向的。

一个可行方案是为每个表面制作一个单独的法线贴图。如果是一个立方体的话我们就需要6个法线贴图,但是如果模型上有无数的朝向不同方向的表面,这就不可行了(译注:实际上对于复杂模型可以把朝向各个方向的法线储存在同一张贴图上,你可能看到过不只是蓝色的法线贴图,不过用那样的法线贴图有个问题是你必须记住模型的起始朝向,如果模型运动了还要记录模型的变换,这是非常不方便的;此外就像作者所说的,如果把一个diffuse纹理应用在同一个物体的不同表面上,就像立方体那样的,就需要做6个法线贴图,这也不可取)。

另一个稍微有点难的解决方案是,在一个不同的坐标空间中进行光照,这个坐标空间里,==法线贴图向量总是指向这个坐标空间的正z方向==;所有的光照向量都相对与这个正z方向进行变换。这样我们就能始终使用同样的法线贴图,不管朝向问题。这个坐标空间叫做==切线空间==(tangent space)。

切线空间

法线贴图中的法线向量定义在切线空间中,==在切线空间中,法线永远指着正z方向==。

解释没啥好看的,不懂的看了依旧不懂,建议快进到TBN的构建

  • 切线空间是位于三角形表面之上的空间:法线相对于单个三角形的本地参考框架。它就像法线贴图向量的本地空间;它们都被定义为指向正z方向,无论最终变换到什么方向。
  • 使用一个特定的矩阵我们就能将本地/切线空间中的法线向量转成世界或视图空间下,使它们转向到最终的贴图表面的方向。

我们可以说,上个部分那个朝向正y的法线贴图错误的贴到了表面上。法线贴图被定义在切线空间中,所以一种解决问题的方式是计算出一种矩阵,把法线从切线空间变换到一个不同的空间,这样它们就能和表面法线方向对齐了:法线向量都会指向正y方向。

切线空间的一大好处是我们可以为任何类型的表面计算出一个这样的矩阵,由此我们可以把切线空间的z方向和表面的法线方向对齐。


这种矩阵叫做==TBN==矩阵这三个字母分别代表tangent、bitangent和normal向量。

这是建构这个矩阵所需的向量。要建构这样一个把切线空间转变为不同空间的变异矩阵,我们需要三个相互垂直的向量,它们沿一个表面的法线贴图对齐于:上、右、前;这和我们在[摄像机教程](https://learnopengl-cn.github.io/01 Getting started/09 Camera/)中做的类似。

已知上向量是表面的法线向量。右和前向量是切线(Tagent)和副切线(Bitangent)向量。下面的图片展示了一个表面的三个向量:

image

计算出切线和副切线并不像法线向量那么容易。从图中可以看到法线贴图的切线和副切线与纹理坐标的两个方向对齐。我们就是用到这个特性计算每个表面的切线和副切线的。需要用到一些数学才能得到它们;请看下图:

image image

有了最后这个等式,我们就可以用公式、三角形的两条边以及纹理坐标计算出切线向量T和副切线B。

如果你对这些数学内容不理解也不用担心。当你知道我们可以用一个三角形的顶点和纹理坐标(因为纹理坐标和切线向量在同一空间中)计算出切线和副切线你就已经部分地达到目的了。(译注:上面的推导已经很清楚了,如果你不明白可以参考任意线性代数教材,就像作者所说的记住求得切线空间的公式也行,不过不管怎样都得理解切线空间的含义)。

个人感觉上面并没有讲很清楚,图没有特别直观,因为计算T、B时切线空间的原点实际和三角形的顶点是重合的,不然理解会比较困难。下面是我的理解

目标:N就是模型空间法线(已知),因此只需要求出==模型空间下的T、B==即可

思路:

  • 首先,TBN是在每个三角形图元上构造的,自然可以让TBN空间的原点是基于某个顶点。
  • 接着,通过三角形各顶点的模型坐标作差,得到两个坐标轴。
  • 再借助纹理坐标来得到两个坐标轴和T、B间的关系,写出关系式
  • 串联整个公式,写出变换矩阵(每个量都已知,TBN的点->E1E2E3->Local),变换矩阵的列向量就是T、B
image

文档里的公式是行向量写法,个人比较不喜欢这种理解方式

手工计算切线和副切线

这个教程的demo场景中有一个简单的2D平面,它朝向正z方向。这次我们会使用切线空间来实现法线贴图,所以我们可以使平面朝向任意方向,法线贴图仍然能够工作。使用前面讨论的数学方法,我们来手工计算出表面的切线和副切线向量。

假设平面使用下面的向量建立起来(1、2、3和1、3、4,它们是两个三角形):

1
2
3
4
5
6
7
8
9
10
11
12
// positions
glm::vec3 pos1(-1.0, 1.0, 0.0);
glm::vec3 pos2(-1.0, -1.0, 0.0);
glm::vec3 pos3(1.0, -1.0, 0.0);
glm::vec3 pos4(1.0, 1.0, 0.0);
// texture coordinates
glm::vec2 uv1(0.0, 1.0);
glm::vec2 uv2(0.0, 0.0);
glm::vec2 uv3(1.0, 0.0);
glm::vec2 uv4(1.0, 1.0);
// normal vector
glm::vec3 nm(0.0, 0.0, 1.0);

我们先计算第一个三角形的边和deltaUV坐标:

1
2
3
4
glm::vec3 edge1 = pos2 - pos1;
glm::vec3 edge2 = pos3 - pos1;
glm::vec2 deltaUV1 = uv2 - uv1;
glm::vec2 deltaUV2 = uv3 - uv1;

有了计算切线和副切线的必备数据,我们就可以开始写出来自于前面部分中的下列等式:

1
2
3
4
5
6
7
8
9
10
11
12
13
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);

tangent1.x = f * (deltaUV2.y * edge1.x - deltaUV1.y * edge2.x);
tangent1.y = f * (deltaUV2.y * edge1.y - deltaUV1.y * edge2.y);
tangent1.z = f * (deltaUV2.y * edge1.z - deltaUV1.y * edge2.z);
tangent1 = glm::normalize(tangent1);

bitangent1.x = f * (-deltaUV2.x * edge1.x + deltaUV1.x * edge2.x);
bitangent1.y = f * (-deltaUV2.x * edge1.y + deltaUV1.x * edge2.y);
bitangent1.z = f * (-deltaUV2.x * edge1.z + deltaUV1.x * edge2.z);
bitangent1 = glm::normalize(bitangent1);

[...] // 对平面的第二个三角形采用类似步骤计算切线和副切线

我们预先计算出等式的分数部分f,然后把它和每个向量的元素进行相应矩阵乘法。如果你把代码和最终的等式对比你会发现,这就是直接套用。最后我们还要进行标准化,来确保切线/副切线向量最后是单位向量。

因为一个三角形永远是平坦的形状,我们只需为每个三角形计算一个切线/副切线,它们对于每个三角形上的顶点都是一样的。要注意的是大多数实现通常三角形和三角形之间都会共享顶点。

这种情况下开发者通常将每个顶点的法线和切线/副切线等顶点属性平均化,以获得更加柔和的效果。

我们的平面的三角形之间分享了一些顶点,但是因为两个三角形相互并行,因此并不需要将结果平均化,但无论何时只要你遇到这种情况记住它就是件好事。

最后的切线和副切线向量的值应该是(1, 0, 0)和(0, 1, 0),它们和法线(0, 0, 1)组成相互垂直的TBN矩阵。在平面上显示出来TBN应该是这样的:

image

每个顶点定义了切线和副切线向量,我们就可以开始实现正确的法线贴图了。

切线空间法线贴图

为让法线贴图工作,我们先得在着色器中创建一个TBN矩阵。我们先将前面计算出来的切线和副切线向量传给顶点着色器,作为它的属性:

1
2
3
4
5
6
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;

在顶点着色器的main函数中我们创建TBN矩阵:

1
2
3
4
5
6
7
8
void main()
{
[...]
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 B = normalize(vec3(model * vec4(bitangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
mat3 TBN = mat3(T, B, N)
}

我们先将所有TBN向量变换到我们所操作的坐标系中,现在是世界空间,我们可以乘以model矩阵。然后我们创建实际的TBN矩阵,直接把相应的向量应用到mat3构造器就行。

注意,如果我们希望==更精确==的话就不要将TBN向量乘以model矩阵,而是使用==法线矩阵==,因为我们只关心向量的方向,不关心平移和缩放。

从技术上讲,顶点着色器中无需副切线。所有的这三个TBN向量都是相互垂直的所以我们可以在顶点着色器中用T和N向量的叉乘,自己计算出副切线:vec3 B = cross(T, N);

现在我们有了TBN矩阵,如果来使用它呢?通常来说有两种方式使用它,我们会把这两种方式都说明一下:

  1. 我们直接使用TBN矩阵,这个矩阵可以把切线坐标空间的向量转换到世界坐标空间。因此我们把它传给片段着色器中,把通过采样得到的法线坐标左乘上TBN矩阵,转换到世界坐标空间中,这样所有法线和其他光照变量就在同一个坐标系中了。
  2. 我们也可以使用TBN矩阵的逆矩阵,这个矩阵可以把世界坐标空间的向量转换到切线坐标空间。因此我们使用这个矩阵左乘其他光照变量,把他们转换到切线空间,这样法线和其他光照变量再一次在一个坐标系中了。

我们来看看第一种情况。(均在世界空间计算)

我们从法线贴图采样得来的法线向量,是在切线空间表示的,尽管其他光照向量都是在世界空间表示的。把TBN传给像素着色器,我们就能将采样得来的切线空间的法线乘以这个TBN矩阵,将法线向量变换到和其他光照向量一样的参考空间中。这种方式随后所有光照计算都可以简单的理解。

把TBN矩阵发给像素着色器很简单:

1
2
3
4
5
6
7
8
9
10
11
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} vs_out;

void main()
{
[...]
vs_out.TBN = mat3(T, B, N);
}

在像素着色器中我们用mat3作为输入变量:

1
2
3
4
5
in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
mat3 TBN;
} fs_in;

有了TBN矩阵我们现在就可以更新法线贴图代码,引入切线到世界空间变换:

1
2
3
normal = texture(normalMap, fs_in.TexCoords).rgb;
normal = normalize(normal * 2.0 - 1.0);
normal = normalize(fs_in.TBN * normal);

因为最后的normal现在在世界空间中了,就不用改变其他像素着色器的代码了,因为光照代码就是假设法线向量在世界空间中。


我们同样看看第二种情况。(均在切线空间计算)

我们用TBN矩阵的逆矩阵将所有相关的世界空间向量转变到采样所得法线向量的空间:切线空间。TBN的建构还是一样,但我们在将其发送给像素着色器之前先要求逆矩阵:

1
vs_out.TBN = transpose(mat3(T, B, N));

注意,这里我们使用transpose函数,而不是inverse函数。正交矩阵(每个轴既是单位向量同时相互垂直)的一大属性是一个正交矩阵的置换矩阵与它的逆矩阵相等。这个属性很重要因为逆矩阵的求得比求置换开销大;结果却是一样的。

在像素着色器中我们不用对法线向量变换,但我们要把其他相关向量转换到切线空间,它们是lightDir和viewDir。这样每个向量还是在同一个空间(切线空间)中了。


第二种方法看似要做的更多,它还需要在像素着色器中进行更多的乘法操作,所以为何还用第二种方法呢?

将向量从世界空间转换到切线空间有个额外好处,我们可以把所有相关向量在顶点着色器中转换到切线空间,不用在像素着色器中做这件事。

这是可行的,因为lightPos和viewPos不是每个fragment运行都要改变,对于fs_in.FragPos,我们也可以在顶点着色器计算它的切线空间位置。基本上,不需要把任何向量在像素着色器中进行变换,而第一种方法中就是必须的,因为采样出来的法线向量对于每个像素着色器都不一样。

所以现在不是把TBN矩阵的逆矩阵发送给像素着色器,而是将切线空间的光源位置,观察位置以及顶点位置发送给像素着色器。这样我们就==不用在像素着色器里进行矩阵乘法==了。这是一个==极佳的优化==,因为顶点着色器通常比像素着色器运行的少。这也是为什么这种方法是一种更好的实现方式的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;

uniform vec3 lightPos;
uniform vec3 viewPos;

[...]

void main()
{
[...]
mat3 TBN = transpose(mat3(T, B, N));
vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vec3(model * vec4(position, 0.0));
}

在像素着色器中我们使用这些新的输入变量来计算切线空间的光照。因为法线向量已经在切线空间中了,光照就有意义了。

将法线贴图应用到切线空间上,我们会得到混合教程一开始那个例子相似的结果,但这次我们可以将平面朝向各个方向,光照一直都会是正确的:

1
2
3
4
glm::mat4 model;
model = glm::rotate(model, (GLfloat)glfwGetTime() * -10, glm::normalize(glm::vec3(1.0, 0.0, 1.0)));
glUniformMatrix4fv(modelLoc 1, GL_FALSE, glm::value_ptr(model));
RenderQuad();

看起来是正确的法线贴图:

image

复杂物体

我们已经说明了如何通过手工计算切线和副切线向量,来使用切线空间和法线贴图。

幸运的是,计算这些切线和副切线向量对于你来说不是经常能遇到的事;大多数时候,在模型加载器中实现了一次就行了,我们是在使用了Assimp的那个加载器中实现的。

Assimp有个很有用的配置,在我们加载模型的时候调用aiProcess_CalcTangentSpace。当aiProcess_CalcTangentSpace应用到Assimp的ReadFile函数时,Assimp会为每个加载的顶点计算出柔和的切线和副切线向量,它所使用的方法和我们本教程使用的类似。

1
2
3
const aiScene* scene = importer.ReadFile(
path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);

我们可以通过下面的代码用Assimp获取计算出来的切线空间:

1
2
3
4
vector.x = mesh->mTangents[i].x;
vector.y = mesh->mTangents[i].y;
vector.z = mesh->mTangents[i].z;
vertex.Tangent = vector;

然后,你还必须更新模型加载器,用以从带纹理模型中加载法线贴图。wavefront的模型格式(.obj)导出的法线贴图有点不一样,Assimp的aiTextureType_NORMAL并不会加载它的法线贴图,而aiTextureType_HEIGHT却能,所以我们经常这样加载它们:

1
2
3
vector<Texture> specularMaps = this->loadMaterialTextures(
material, aiTextureType_HEIGHT, "texture_normal"
);

当然,对于每个模型的类型和文件格式来说都是不同的。同样了解aiProcess_CalcTangentSpace并不能总是很好的工作也很重要。

计算切线是需要根据纹理坐标的,有些模型制作者使用一些纹理小技巧比如镜像一个模型上的纹理表面时也镜像了另一半的纹理坐标;这样当不考虑这个镜像的特别操作的时候(Assimp就不考虑)结果就不对了。

运行程序,用新的模型加载器,加载一个有specular和法线贴图的模型,看起来会像这样:

image

你可以看到在没有太多点的额外开销的情况下法线贴图难以置信地提升了物体的细节。


使用法线贴图也是一种提升你的场景的表现的重要方式。在使用法线贴图之前你不得不使用相当多的顶点才能表现出一个更精细的网格,但使用了法线贴图我们可以使用更少的顶点表现出同样丰富的细节。下图来自Paolo Cignoni,图中对比了两种方式:

image

高精度网格和使用法线贴图的低精度网格几乎区分不出来。所以法线贴图不仅看起来漂亮,它也是一个将高精度多边形转换为低精度多边形而不失细节的重要工具。

最后一件事

关于法线贴图还有最后一个技巧要讨论,它可以在不必花费太多性能开销的情况下稍稍提升画质表现。

当在更大的网格上计算切线向量的时候,它们往往有很大数量的共享顶点,当法向贴图应用到这些表面时将切线向量平均化通常能获得更好更平滑的结果。这样做有个问题,就是TBN向量可能会不能互相垂直,这意味着TBN矩阵不再是正交矩阵了。法线贴图可能会稍稍偏移,但这仍然可以改进。

使用叫做格拉姆-施密特正交化过程(Gram-Schmidt process)的数学技巧,我们可以对TBN向量进行重正交化,这样每个向量就又会重新垂直了。在顶点着色器中我们这样做:

1
2
3
4
5
6
7
8
vec3 T = normalize(vec3(model * vec4(tangent, 0.0)));
vec3 N = normalize(vec3(model * vec4(normal, 0.0)));
// re-orthogonalize T with respect to N
T = normalize(T - dot(T, N) * N);
// then retrieve perpendicular vector B with the cross product of T and N
vec3 B = cross(T, N);

mat3 TBN = mat3(T, B, N)

这样稍微花费一些性能开销就能对法线贴图进行一点提升。看看最后的那个附加资源: Normal Mapping Mathematics视频,里面有对这个过程的解释。

附加资源

5.5 视差贴图

==视差贴图==(Parallax Mapping)技术和法线贴图差不多,但它有着不同的原则。和法线贴图一样视差贴图能够极大提升表面细节,使之具有深度感。它也是利用了==视错觉==,然而==对深度有着更好的表达==,==与法线贴图一起用==能够产生难以置信的效果。

==视差贴图和光照无关==,我在这里是作为法线贴图的技术延续来讨论它的。需要注意的是在开始学习视差贴图之前强烈建议先对法线贴图,特别是切线空间有较好的理解。


视差贴图属于位移贴图(Displacement Mapping)技术的一种,它对根据储存在纹理中的几何信息对顶点进行位移或偏移。

一种实现的方式是比如有1000个顶点,根据纹理中的数据对平面特定区域的顶点的高度进行位移。这样的每个纹理像素包含了高度值纹理叫做==高度贴图==。一张简单的砖块表面的高度贴图如下所示:

image

整个平面上的每个顶点都根据从高度贴图采样出来的高度值进行位移,根据材质的几何属性平坦的平面变换成凹凸不平的表面。例如一个平坦的平面利用上面的高度贴图进行置换能得到以下结果:

image

置换顶点有一个问题就是平面必须由很多顶点组成才能获得具有真实感的效果,否则看起来效果并不会很好。一个平坦的表面上有1000个顶点计算量太大了。我们能否不用这么多的顶点就能取得相似的效果呢?

事实上,上面的表面就是用6个顶点渲染出来的(两个三角形)。上面的那个表面使用视差贴图技术渲染,位移贴图技术不需要额外的顶点数据来表达深度,它像法线贴图一样采用一种聪明的手段欺骗用户的眼睛。


原理

==视差贴图背后的思想==是==修改纹理坐标==使一个fragment的表面看起来比实际的更高或者更低,所有这些都根据观察方向和高度贴图。为了理解它如何工作,看看下面砖块表面的图片:

这里粗糙的红线代表高度贴图中的数值的立体表达,向量$\overline{V}$代表观察方向。如果平面进行实际位移,观察者会在点B看到表面。然而我们的平面没有实际上进行位移,观察方向将在点A与平面接触。

视差贴图的==目的==是,在A位置上的fragment不再使用点A的纹理坐标而是使用点B的。随后我们用点B的纹理坐标采样,观察者就像看到了点B一样。

这个技巧就是描述如何从点A得到点B的纹理坐标。视差贴图尝试通过对从fragment到观察者的方向向量$\overline{V}$进行缩放的方式解决这个问题,==缩放的大小是A处fragment的高度==。所以我们将$\overline{V}$的长度缩放为高度贴图在点A处$H(A)$采样得来的值。下图展示了经缩放得到的向量$\overline{P}$:

image

纹理坐标偏移量是根据观察方向和高度贴图共同计算得到的

  • 不使用视差贴图:采样绿色点
  • 使用视差贴图:采样咖啡点(图中没画,指的是图中咖啡点上投影到纹理坐标的点)
  • 实际:采样蓝点(投影)

我们随后选出$\overline{P}$以及这个向量与平面对齐的坐标作为纹理坐标的偏移量。这能工作是因为向量$\overline{P}$是使用从高度贴图得到的高度值计算出来的,所以一个fragment的高度越高位移的量越大。


这个技巧在大多数时候都没问题,但点B是粗略估算得到的。==当表面的高度变化很快==的时候,看起来就==不会真实==,因为向量$\overline{P}$最终不会和B接近,就像下图这样:

image

上图实际应该从蓝点对应的位置采样,可最终采样的是咖啡点

视差贴图的另一个问题是,当表面被任意旋转以后很难指出从$\overline{P}$获取哪一个坐标。我们在视差贴图中使用了另一个坐标空间,这个空间$\overline{P}$向量的x和y元素总是与纹理表面对齐。如果你看了法线贴图教程,你也许猜到了,我们实现它的方法,是的,我们还是==在切线空间中实现视差贴图==。


结论

将fragment到观察者的向量$\overline{V}$转换到切线空间中,经变换的$\overline{P}$向量的x和y元素将于表面的切线和副切线向量对齐。由于切线和副切线向量与表面纹理坐标的方向相同,我们可以==用$\overline{P}$的x和y元素作为纹理坐标的偏移量==,这样就不用考虑表面的方向了。

理论都有了,下面我们来动手实现视差贴图。

使用视差贴图

我们将使用一个简单的2D平面,在把它发送给GPU之前我们==先计算它的切线和副切线向量==;和法线贴图教程做的差不多。我们将向平面贴diffuse纹理、法线贴图以及一个位移贴图,你可以点击链接下载。

这个例子中我们将==视差贴图和法线贴图连用==(用视差偏移后的纹理坐标去采样法线贴图)。因为视差贴图生成表面位移了的幻觉,当光照不匹配时这种幻觉就被破坏了。法线贴图通常根据高度贴图生成,法线贴图和高度贴图一起用能保证光照能和位移想匹配。


你可能已经注意到,上面链接上的那个位移贴图和教程一开始的那个高度贴图相比是颜色是相反的。这是因为使用反色高度贴图(也叫==深度贴图==)去模拟深度比模拟高度更容易。下图反映了这个轻微的改变:

image

解释:对于砖墙表面而言,基准是平坦的墙面,墙面的缝隙间就会有不同的深度,所以这边使用深度图更好理解。如果对地形而言,基准是地面,此时用高度图会更好理解。

两者本质上都是一个东西,最后计算纹理坐标偏移的时候,高度图是加偏移量,深度图是减偏移量罢了。

我们再次获得A和B,但是这次我们用向量$\overline{V}$减去点A的纹理坐标得到$\overline{P}$。我们通过在着色器中用1.0减去采样得到的高度贴图中的值来取得深度值,而不再是高度值,或者简单地在图片编辑软件中把这个纹理进行反色操作,就像我们对连接中的那个深度贴图所做的一样。

位移贴图是在像素着色器中实现的,因为三角形表面的所有位移效果都不同。在像素着色器中我们将需要计算fragment到观察者到方向向量$\overline{V}$,所以我们需要观察者位置和在切线空间中的fragment位置。法线贴图教程中我们已经有了一个顶点着色器,它把这些向量发送到切线空间,所以我们可以复制那个顶点着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
layout (location = 3) in vec3 tangent;
layout (location = 4) in vec3 bitangent;

out VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;

uniform vec3 lightPos;
uniform vec3 viewPos;

void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.TexCoords = texCoords;

vec3 T = normalize(mat3(model) * tangent);
vec3 B = normalize(mat3(model) * bitangent);
vec3 N = normalize(mat3(model) * normal);
mat3 TBN = transpose(mat3(T, B, N));

vs_out.TangentLightPos = TBN * lightPos;
vs_out.TangentViewPos = TBN * viewPos;
vs_out.TangentFragPos = TBN * vs_out.FragPos;
}

在这里有件事很重要,我们需要把position和在切线空间中的观察者的位置viewPos发送给像素着色器。


在像素着色器中,我们实现视差贴图的逻辑。像素着色器看起来会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#version 330 core
out vec4 FragColor;

in VS_OUT {
vec3 FragPos;
vec2 TexCoords;
vec3 TangentLightPos;
vec3 TangentViewPos;
vec3 TangentFragPos;
} fs_in;

uniform sampler2D diffuseMap;
uniform sampler2D normalMap;
uniform sampler2D depthMap;

uniform float height_scale;

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir);

void main()
{
// Offset texture coordinates with Parallax Mapping
vec3 viewDir = normalize(fs_in.TangentViewPos - fs_in.TangentFragPos);
vec2 texCoords = ParallaxMapping(fs_in.TexCoords, viewDir);

// then sample textures with new texture coords
vec3 diffuse = texture(diffuseMap, texCoords);
vec3 normal = texture(normalMap, texCoords);
normal = normalize(normal * 2.0 - 1.0);
// proceed with lighting code
[...]
}

我们定义了一个叫做ParallaxMapping的函数,它把fragment的纹理坐标和切线空间中的fragment到观察者的方向向量为输入。

这个函数返回经位移的纹理坐标。然后我们使用这些经位移的纹理坐标进行diffuse和法线贴图的采样。最后fragment的diffuse颜色和法线向量就正确的对应于表面的经位移的位置上了。

我们来看看ParallaxMapping函数的内部:

1
2
3
4
5
6
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
float height = texture(depthMap, texCoords).r;
vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
return texCoords - p; //深度图就是减
}

这个相对简单的函数是我们所讨论过的内容的直接表述。

  • 我们用本来的纹理坐标texCoords从高度贴图中来采样,得到当前fragment的高度$H(A)$。
  • 然后计算出$\overline{P}$,x和y元素在切线空间中,viewDir向量除以它的z元素,用fragment的高度对它进行缩放。
  • 我们同时引入额一个height_scale的uniform,来进行一些额外的控制,因为视差效果如果没有一个缩放参数通常会过于强烈。
  • 然后我们用$\overline{P}$减去纹理坐标来获得最终的经过位移纹理坐标。

有一个地方需要注意,就是viewDir.xy除以viewDir.z那里。因为viewDir向量是经过了标准化的,viewDir.z会在0.0到1.0之间的某处。当viewDir大致平行于表面时,它的z元素接近于0.0,除法会返回比viewDir垂直于表面的时候更大的$\overline{P}$向量。

所以,从本质上,相比正朝向表面,当带有角度地看向平面时,我们会更大程度地缩放$\overline{P}$的大小,从而增加纹理坐标的偏移;这样做在视角上会获得更大的真实度。

viewDir.xy / viewDir.z 本质上经验Trick

  • 首先viewDir是单位向量,长度为1
  • 当viewDir.z -> 1时,视线接近垂直,此时xy都小,z分量≈1,结果就是纹理坐标偏移小
  • 当viewDir.z -> 0 时,视线接近平行,此时xy都大,z分量≈0,结果就是纹理坐标偏移大

有些人更喜欢==不在等式中使用viewDir.z==,因为普通的视差贴图会在角度上产生不尽如人意的结果;这个技术叫做有偏移量限制的视差贴图(Parallax Mapping with Offset Limiting)。选择哪一个技术是个人偏好问题,但我倾向于普通的视差贴图。


最后的纹理坐标随后被用来进行采样(diffuse和法线)贴图,下图所展示的位移效果中height_scale等于0.1:

image

这里你会看到只用法线贴图和与视差贴图相结合的法线贴图的不同之处。因为视差贴图尝试模拟深度,它实际上能够根据你观察它们的方向使砖块叠加到其他砖块上。


在视差贴图的那个平面里你仍然能看到在==边上有古怪的失真==。原因是在平面的边缘上,纹理坐标超出了0到1的范围进行采样,根据纹理的环绕方式导致了不真实的结果。==解决的方法==是当它超出默认纹理坐标范围进行采样的时候就丢弃这个fragment:

1
2
3
texCoords = ParallaxMapping(fs_in.TexCoords,  viewDir);
if(texCoords.x > 1.0 || texCoords.y > 1.0 || texCoords.x < 0.0 || texCoords.y < 0.0)
discard;

丢弃了超出默认范围的纹理坐标的所有fragment,视差贴图的表面边缘给出了正确的结果。注意,这个技巧不能在所有类型的表面上都能工作,但是应用于平面上它还是能够是平面看起来真的进行位移了:

image

看起来不错,运行起来也很快,因为我们只要给视差贴图提供一个额外的纹理样本就能工作。当从一个角度看过去的时候,会有一些问题产生(和法线贴图相似),陡峭的地方会产生不正确的结果,从下图你可以看到:

image

==问题的原因==是这只是一个==大致近似的视差映射==。还有==一些技巧==让我们在==陡峭的高度上能够获得几乎完美的结果==,即使当以一定角度观看的时候。例如,我们不再使用单一样本,取而代之使用多样本来找到最近点B会得到怎样的结果?

陡峭视差映射

本质:多次采样

陡峭视差映射(Steep Parallax Mapping)是视差映射的扩展,原则是一样的,但不是使用一个样本而是多个样本来确定向量$\overline{P}$到B。即使在陡峭的高度变化的情况下,它也能得到更好的结果,原因在于该技术通过==增加采样的数量提高了精确性==。

陡峭视差映射的==基本思想==是将总深度范围划分为同一个深度/高度的多个层。从每个层中我们沿着$\overline{P}$方向移动采样纹理坐标,直到我们找到一个采样低于当前层的深度值。看看下面的图片:

image

我们从上到下遍历深度层,我们把每个深度层和储存在深度贴图中的它的深度值进行对比。如果这个层的深度值小于深度贴图的值,就意味着这一层的$\overline{P}$向量部分在表面之下。我们继续这个处理过程直到有一层的深度高于储存在深度贴图中的值:这个点就在(经过位移的)表面下方。

原理没有很清楚,大概多采样点采样,流程能记住就行。

大致流程:从绿色点开始,间隔一段距离采样一次(依次采样紫色点处投影点的视差贴图值)。直到采样结果值小于层数值结束。

这个例子中我们可以看到第二层(D(2) = 0.73)的深度贴图的值仍低于第二层的深度值0.4,所以我们继续。下一次迭代,这一层的深度值0.6大于深度贴图中采样的深度值(D(3) = 0.37)。我们便可以假设第三层向量$\overline{P}$是可用的位移几何位置。我们可以用从向量$\overline{P_3}$的纹理坐标偏移$\overline{T_3}$来对fragment的纹理坐标进行位移。

你可以看到随着深度值的增加精确度也在提高。

为实现这个技术,我们只需要改变ParallaxMapping函数,因为所有需要的变量都有了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{
// number of depth layers
const float numLayers = 10;
// calculate the size of each layer
float layerDepth = 1.0 / numLayers;
// depth of current layer
float currentLayerDepth = 0.0;
// the amount to shift the texture coordinates per layer (from vector P)
vec2 P = viewDir.xy * height_scale;
vec2 deltaTexCoords = P / numLayers;

[...]
}

我们先定义层的数量,计算每一层的深度,最后计算纹理坐标偏移,每一层我们必须沿着$\overline{P}$的方向进行移动。

然后我们遍历所有层,从上开始,直到找到小于这一层的深度值的深度贴图值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// get initial values
vec2 currentTexCoords = texCoords;
float currentDepthMapValue = texture(depthMap, currentTexCoords).r;

while(currentLayerDepth < currentDepthMapValue)
{
// shift texture coordinates along direction of P
currentTexCoords -= deltaTexCoords;
// get depthmap value at current texture coordinates
currentDepthMapValue = texture(depthMap, currentTexCoords).r;
// get depth of next layer
currentLayerDepth += layerDepth;
}

return currentTexCoords;

这里我们循环每一层深度,直到沿着$\overline{P}$向量找到第一个返回低于(位移)表面的深度的纹理坐标偏移量。从fragment的纹理坐标减去最后的偏移量,来得到最终的经过位移的纹理坐标向量,这次就比传统的视差映射更精确了。

有10个样本砖墙从一个角度看上去就已经很好了,但是当有一个强前面展示的木制表面一样陡峭的表面时,陡峭的视差映射的威力就显示出来了:

image

我们可以通过对视差贴图的一个属性的利用,对算法进行一点提升。当垂直看一个表面的时候纹理时位移比以一定角度看时的小。我们可以在垂直看时使用更少的样本,以一定角度看时增加样本数量:

1
2
3
const float minLayers = 8;
const float maxLayers = 32;
float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));

这里我们得到viewDir和正z方向的点乘,使用它的结果根据我们看向表面的角度调整样本数量(注意正z方向等于切线空间中的表面的法线)。如果我们所看的方向平行于表面,我们就是用32层。

陡峭视差贴图同样有自己的问题。因为这个技术是基于有限的样本数量的,我们会遇到锯齿效果以及图层之间有明显的断层:

image

我们可以通过增加样本的方式减少这个问题,但是很快就会花费很多性能。有些旨在修复这个问题的方法:不适用低于表面的第一个位置,而是在两个接近的深度层进行插值找出更匹配B的。

两种最流行的解决方法叫做Relief Parallax Mapping和Parallax Occlusion Mapping,Relief Parallax Mapping更精确一些,但是比Parallax Occlusion Mapping性能开销更多。因为Parallax Occlusion Mapping的效果和前者差不多但是效率更高,因此这种方式更经常使用,所以我们将在下面讨论一下。

视差遮蔽映射

本质:线性插值

==视差遮蔽映射==(Parallax Occlusion Mapping)和陡峭视差映射的原则相同,但不是用触碰的第一个深度层的纹理坐标,而是在触碰之前和之后,在==深度层之间进行线性插值==。

我们根据表面的高度距离哪个深度层的深度层值的距离来确定线性插值的大小。看看下面的图片就能了解它是如何工作的:

image

你可以看到大部分和陡峭视差映射一样,不一样的地方是有个额外的步骤,两个深度层的纹理坐标围绕着交叉点的线性插值。这也是近似的,但是比陡峭视差映射更精确。

视差遮蔽映射的代码基于陡峭视差映射,所以并不难:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[...] // steep parallax mapping code here

// get texture coordinates before collision (reverse operations)
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;

// get depth after and before collision for linear interpolation
float afterDepth = currentDepthMapValue - currentLayerDepth; //0.37-0.6=-0.23
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth; // 0.73-0.6+0.2=0.33

// interpolation of texture coordinates
float weight = afterDepth / (afterDepth - beforeDepth); // -0.23 / -0.23-0.33=23/56
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight); //采样值和层值越接近,权重就越大

return finalTexCoords;

在对(位移的)表面几何进行交叉,找到深度层之后,我们获取交叉前的纹理坐标。

然后我们计算来自相应深度层的几何之间的深度之间的距离,并在两个值之间进行插值。线性插值的方式是在两个层的纹理坐标之间进行的基础插值。函数最后返回最终的经过插值的纹理坐标。

视差遮蔽映射的效果非常好,尽管有一些可以看到的轻微的不真实和锯齿的问题,这仍是一个好交易,因为除非是放得非常大或者观察角度特别陡,否则也看不到。

image

视差贴图是提升场景细节非常好的技术,但是使用的时候还是要考虑到它会带来一点不自然。大多数时候视差贴图用在地面和墙壁表面,这种情况下查明表面的轮廓并不容易,同时观察角度往往趋向于垂直于表面。这样视差贴图的不自然也就很难能被注意到了,对于提升物体的细节可以祈祷难以置信的效果。

附加资源

5.6 HDR

一般来说,当存储在帧缓冲(Framebuffer)中时,亮度和颜色的值是默认被限制在0.0到1.0之间的。这个看起来无辜的语句使我们一直将亮度与颜色的值设置在这个范围内,尝试着与场景契合。

这样是能够运行的,也能给出还不错的效果。但是如果我们遇上了一个特定的区域,其中有多个亮光源使这些数值总和超过了1.0,又会发生什么呢?

答案是这些片段中超过1.0的亮度或者颜色值会被约束在1.0,从而导致场景混成一片,难以分辨:

image

这是由于大量片段的颜色值都非常接近1.0,在很大一个区域内每一个亮的片段都有相同的白色。这损失了很多的细节,使场景看起来非常假。


解决这个问题的一个方案是减小光源的强度从而保证场景内没有一个片段亮于1.0。然而这并不是一个好的方案,因为你需要使用不切实际的光照参数。

一个更好的方案是让颜色暂时超过1.0,然后将其转换至0.0到1.0的区间内,从而防止损失细节。

显示器被限制为只能显示值为0.0到1.0间的颜色,但是在光照方程中却没有这个限制。通过使片段的颜色超过1.0,我们有了一个更大的颜色范围,这也被称作**HDR(High Dynamic Range, 高动态范围)**。有了HDR,亮的东西可以变得非常亮,暗的东西可以变得非常暗,而且充满细节。

HDR原本只是被运用在摄影上,摄影师对同一个场景采取不同曝光拍多张照片,捕捉大范围的色彩值。这些图片被合成为HDR图片,从而综合不同的曝光等级使得大范围的细节可见。

看下面这个例子,左边这张图片在被光照亮的区域充满细节,但是在黑暗的区域就什么都看不见了;但是右边这张图的高曝光却可以让之前看不出来的黑暗区域显现出来。

image

这与我们眼睛工作的原理非常相似,也是HDR渲染的基础。当光线很弱的啥时候,人眼会自动调整从而使过暗和过亮的部分变得更清晰,就像人眼有一个能自动根据场景亮度调整的自动曝光滑块。

HDR渲染和其很相似,我们允许==用更大范围的颜色值渲染==从而==获取大范围的黑暗与明亮的场景细节==,最后将所有HDR值==转换成在[0.0, 1.0]范围==的LDR(Low Dynamic Range,低动态范围)。转换HDR值到LDR值得过程叫做==色调映射==(Tone Mapping),现在现存有很多的色调映射算法,这些算法致力于在转换过程中保留尽可能多的HDR细节。这些色调映射算法经常会包含一个选择性倾向黑暗或者明亮区域的参数。

在实时渲染中,HDR不仅允许我们超过LDR的范围[0.0, 1.0]与保留更多的细节,同时还让我们能够根据光源的真实强度指定它的强度。

  • 比如太阳有比闪光灯之类的东西更高的强度,那么我们为什么不这样子设置呢?(比如说设置一个10.0的漫亮度) 这允许我们用更现实的光照参数恰当地配置一个场景的光照,而这在LDR渲染中是不能实现的,因为他们会被上限约束在1.0。

因为显示器只能显示在0.0到1.0范围之内的颜色,我们肯定要做一些转换从而使得当前的HDR颜色值符合显示器的范围。简单地取平均值重新转换这些颜色值并不能很好的解决这个问题,因为明亮的地方会显得更加显著。

我们能做的是用一个不同的方程与/或曲线来转换这些HDR值到LDR值,从而给我们对于场景的亮度完全掌控,这就是之前说的色调变换,也是HDR渲染的最终步骤。

浮点帧缓冲

在实现HDR渲染之前,我们首先需要一些==防止颜色值在每一个片段着色器运行后被限制约束==的方法。

  • 当帧缓冲使用了一个标准化的定点格式(像GL_RGB)为其颜色缓冲的内部格式,OpenGL会在将这些值存入帧缓冲前自动将其约束到0.0到1.0之间。
  • 这一操作对大部分帧缓冲格式都是成立的,除了专门用来存放被拓展范围值的浮点格式。

当一个帧缓冲的颜色缓冲的内部格式被设定成了GL_RGB16F, GL_RGBA16F, GL_RGB32F 或者GL_RGBA32F时,这些帧缓冲被叫做==浮点帧缓冲==(Floating Point Framebuffer),浮点帧缓冲可以存储超过0.0到1.0范围的浮点值,所以非常适合HDR渲染。

想要创建一个浮点帧缓冲,我们只需要改变颜色缓冲的内部格式参数就行了(注意GL_FLOAT参数):

1
2
glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);

默认的帧缓冲默认一个颜色分量只占用8位(bits)。当使用一个使用32位每颜色分量的浮点帧缓冲时(使用GL_RGB32F 或者GL_RGBA32F),我们需要四倍的内存来存储这些颜色。所以除非你需要一个非常高的精确度,32位不是必须的,使用GLRGB16F就足够了。


有了一个带有浮点颜色缓冲的帧缓冲,我们可以放心渲染场景到这个帧缓冲中。

在这个教程的例子当中,我们先渲染一个光照的场景到浮点帧缓冲中,之后再在一个铺屏四边形(Screen-filling Quad)上应用这个帧缓冲的颜色缓冲,代码会是这样子:

1
2
3
4
5
6
7
8
9
10
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// [...] 渲染(光照的)场景
glBindFramebuffer(GL_FRAMEBUFFER, 0);

// 现在使用一个不同的着色器将HDR颜色缓冲渲染至2D铺屏四边形上
hdrShader.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrColorBufferTexture);
RenderQuad();

这里场景的颜色值存在一个可以包含任意颜色值的浮点颜色缓冲中,值可能是超过1.0的。


这个简单的演示中,场景被创建为一个被拉伸的立方体通道和四个点光源,其中一个非常亮的在隧道的尽头:

1
2
3
4
5
std::vector<glm::vec3> lightColors;
lightColors.push_back(glm::vec3(200.0f, 200.0f, 200.0f));
lightColors.push_back(glm::vec3(0.1f, 0.0f, 0.0f));
lightColors.push_back(glm::vec3(0.0f, 0.0f, 0.2f));
lightColors.push_back(glm::vec3(0.0f, 0.1f, 0.0f));

渲染至浮点帧缓冲和渲染至一个普通的帧缓冲是一样的。

新的东西就是这个的hdrShader的片段着色器,用来渲染最终拥有浮点颜色缓冲纹理的2D四边形。我们来定义一个简单的直通片段着色器(Pass-through Fragment Shader):

1
2
3
4
5
6
7
8
9
10
11
#version 330 core
out vec4 color;
in vec2 TexCoords;

uniform sampler2D hdrBuffer;

void main()
{
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
color = vec4(hdrColor, 1.0);
}

这里我们直接采样了浮点颜色缓冲并将其作为片段着色器的输出。然而,这个2D四边形的输出是被直接渲染到默认的帧缓冲中,导致所有片段着色器的输出值被约束在0.0到1.0间,尽管我们已经有了一些存在浮点颜色纹理的值超过了1.0。

image

很明显,在隧道尽头的强光的值被约束在1.0,因为一大块区域都是白色的,过程中超过1.0的地方损失了所有细节。

因为我们直接转换HDR值到LDR值,这就像我们根本就没有应用HDR一样。为了修复这个问题我们需要做的是无损转化所有浮点颜色值回0.0-1.0范围中。我们需要应用到色调映射。

色调映射

色调映射(Tone Mapping)是一个损失很小的转换浮点颜色值至我们所需的LDR[0.0, 1.0]范围内的过程,通常会伴有特定的风格的色平衡(Stylistic Color Balance)。

最简单的色调映射算法是Reinhard色调映射,它涉及到分散整个HDR颜色值到LDR颜色值上,所有的值都有对应。

Reinhard 算法

==Reinhard==色调映射算法==平均地将所有亮度值分散到LDR上==。

我们将Reinhard色调映射应用到之前的片段着色器上,并且为了更好的测量加上一个Gamma校正过滤(包括SRGB纹理的使用):

1
2
3
4
5
6
7
8
9
10
11
12
void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

// Reinhard色调映射
vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));

color = vec4(mapped, 1.0);
}

有了Reinhard色调映射的应用,我们不再会在场景明亮的地方损失细节。当然,这个算法是倾向明亮的区域的,暗的区域会不那么精细也不那么有区分度。

image

现在你可以看到在隧道的尽头木头纹理变得可见了。

用了这个非常简单地色调映射算法,我们可以合适的看到存在浮点帧缓冲中整个范围的HDR值,使我们能在不丢失细节的前提下,对场景光照有精确的控制。


应用Exposure

另一个有趣的色调映射应用是曝光(Exposure)参数的使用。你可能还记得之前我们在介绍里讲到的,HDR图片包含在不同曝光等级的细节。

如果我们有一个场景要展现日夜交替,我们当然会在白天使用低曝光,在夜间使用高曝光,就像人眼调节方式一样。有了这个曝光参数,我们可以去设置可以同时在白天和夜晚不同光照条件工作的光照参数,我们只需要调整曝光参数就行了。

一个简单的曝光色调映射算法会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uniform float exposure;

void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;

// 曝光色调映射
vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
// Gamma校正
mapped = pow(mapped, vec3(1.0 / gamma));

color = vec4(mapped, 1.0);
}

在这里我们将exposure定义为默认为1.0的uniform,从而允许我们更加精确设定我们是要注重黑暗还是明亮的区域的HDR颜色值。

举例来说,高曝光值会使隧道的黑暗部分显示更多的细节,然而低曝光值会显著减少黑暗区域的细节,但允许我们看到更多明亮区域的细节。

下面这组图片展示了在不同曝光值下的通道:

image

这个图片清晰地展示了HDR渲染的优点。通过改变曝光等级,我们可以看见场景的很多细节,而这些细节可能在LDR渲染中都被丢失了。

比如说隧道尽头,在正常曝光下木头结构隐约可见,但用低曝光木头的花纹就可以清晰看见了。对于近处的木头花纹来说,在高曝光下会能更好的看见。

HDR拓展

在这里展示的两个色调映射算法仅仅是大量(更先进)的色调映射算法中的一小部分,这些算法各有长短。

  • 一些色调映射算法倾向于特定的某种颜色/强度
  • 也有一些算法同时显示低高曝光颜色从而能够显示更加多彩和精细的图像。
  • 也有一些技巧被称作自动曝光调整(Automatic Exposure Adjustment)或者叫人眼适应(Eye Adaptation)技术,它能够检测前一帧场景的亮度并且缓慢调整曝光参数模仿人眼使得场景在黑暗区域逐渐变亮或者在明亮区域逐渐变暗。

HDR渲染的真正优点在庞大和复杂的场景中应用复杂光照算法会被显示出来,但是出于教学目的创建这样复杂的演示场景是很困难的,这个教程用的场景是很小的,而且缺乏细节。

但是如此简单的演示也是能够显示出HDR渲染的一些优点:

  • 在明亮和黑暗区域无细节损失,因为它们可以通过色调映射重新获得;
  • 多个光照的叠加不会导致亮度被截断的区域的出现,光照可以被设定为它们原来的亮度值而不是被LDR值限制。

附加资源

5.7 泛光

明亮的光源和区域经常很难向观察者表达出来,因为监视器的亮度范围是有限的。一种区分明亮光源的方式是使它们在监视器上发出光芒,光源的光芒向四周发散。这样观察者就会产生光源或亮区的确是强光区。

(译注:这个问题的提出简单来说是为了解决这样的问题:例如有一张在阳光下的白纸,白纸在监视器上显示出是出白色,而前方的太阳也是纯白色的,所以基本上白纸和太阳就是一样的了,给太阳加一个光晕,这样太阳看起来似乎就比白纸更亮了)

光晕效果可以使用一个后处理特效泛光来实现。泛光使所有明亮区域产生光晕效果。下面是一个使用了和没有使用光晕的对比(图片生成自虚幻引擎):

image

Bloom是我们能够注意到一个明亮的物体真的有种明亮的感觉。泛光可以极大提升场景中的光照效果,并提供了极大的效果提升,尽管做到这一切只需一点改变。


Bloom和HDR结合使用效果很好。常见的一个误解是HDR和泛光是一样的,很多人认为两种技术是可以互换的。但是它们是两种不同的技术,用于各自不同的目的上。

可以使用默认的8位精确度的帧缓冲,也可以在不使用泛光效果的时候,使用HDR。只不过在有了HDR之后再实现泛光就更简单了。

为==实现泛光==,我们像平时那样渲染一个有光场景,==提取==出场景的HDR颜色缓冲以及只有这个场景明亮区域可见的图片。被提取的带有亮度的图片接着被==模糊==,结果被==添加==到HDR场景上面。


我们来一步一步解释这个处理过程。我们在场景中渲染一个带有4个立方体形式不同颜色的明亮的光源。带有颜色的发光立方体的亮度在1.5到15.0之间。如果我们将其渲染至HDR颜色缓冲,场景看起来会是这样的:

image

我们得到这个HDR颜色缓冲纹理,提取所有超出一定亮度的fragment。这样我们就会获得一个只有fragment超过了一定阈限的颜色区域:

image

我们将这个超过一定亮度阈限的纹理进行模糊。泛光效果的强度很大程度上是由被模糊过滤器的范围和强度所决定。

image

最终的被模糊化的纹理就是我们用来获得发出光晕效果的东西。这个已模糊的纹理要添加到原来的HDR场景纹理之上。因为模糊过滤器的应用明亮区域发出光晕,所以明亮区域在长和宽上都有所扩展。

image

泛光本身并不是个复杂的技术,但很难获得正确的效果。它的品质很大程度上取决于所用的模糊过滤器的质量和类型。简单地改改模糊过滤器就会极大的改变泛光效果的品质。

image

提取亮色 (多渲染目标MRT)

第一步我们要从渲染出来的场景中提取两张图片。我们可以渲染场景两次,每次使用一个不同的着色器渲染到不同的帧缓冲中,但我们可以使用一个叫做==MRT==(Multiple Render Targets,多渲染目标)的小技巧,这样我们就能指定多个像素着色器输出;

有了它我们还能够在一个单独渲染处理中提取头两个图片。在像素着色器的输出前,我们指定一个布局location标识符,这样我们便可控制一个像素着色器写入到哪个颜色缓冲:

1
2
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;

只有我们真的具有多个地方可写的时候这才能工作。使用多个像素着色器输出的必要条件是,==有多个颜色缓冲附加到了当前绑定的帧缓冲对象上==。

你可能从帧缓冲教程那里回忆起,当把一个纹理链接到帧缓冲的颜色缓冲上时,我们可以指定一个颜色附件。直到现在,我们一直使用着GL_COLOR_ATTACHMENT0,但通过使用GL_COLOR_ATTACHMENT1,我们可以得到一个附加了两个颜色缓冲的帧缓冲对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Set up floating point framebuffer to render scene to
GLuint hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
GLuint colorBuffers[2];
glGenTextures(2, colorBuffers);
for (GLuint i = 0; i < 2; i++)
{
glBindTexture(GL_TEXTURE_2D, colorBuffers[i]);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// attach texture to framebuffer
glFramebufferTexture2D(
GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
);
}

我们需要==显式==告知OpenGL我们正在通过glDrawBuffers渲染到多个颜色缓冲,否则OpenGL只会渲染到帧缓冲的第一个颜色附件,而忽略所有其他的。

我们可以通过传递多个颜色附件的枚举来做这件事,我们以下面的操作进行渲染:

1
2
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
glDrawBuffers(2, attachments);

上述代码通常也是在帧缓冲初始化阶段调用一次即可

当渲染到这个帧缓冲的时候,一个着色器使用一个布局location修饰符,fragment就会写入对应的颜色缓冲。

这很棒,因为这样省去了我们为提取明亮区域的额外渲染步骤,因为我们现在可以直接从将被渲染的fragment提取出它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;

[...]

void main()
{
[...] // first do normal lighting calculations and output results
FragColor = vec4(lighting, 1.0f);
// Check whether fragment output is higher than threshold, if so output as brightness color
float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
if(brightness > 1.0)
BrightColor = vec4(FragColor.rgb, 1.0);
}

这里我们先正常计算光照,将其传递给第一个像素着色器的输出变量FragColor。

然后我们使用当前储存在FragColor的东西来决定它的亮度是否超过了一定阈限。我们通过恰当地将其转为灰度的方式计算一个fragment的亮度,如果它超过了一定阈限,我们就把颜色输出到第二个颜色缓冲,那里保存着所有亮部;渲染发光的立方体也是一样的。

这也说明了为什么泛光在HDR基础上能够运行得很好。因为HDR中,我们可以将颜色值指定超过1.0这个默认的范围,我们能够得到对一个图像中的亮度的更好的控制权。==没有HDR==我们必须将阈限设置为小于1.0的数,虽然可行,但是亮部很容易变得很多,这就导致==光晕效果过重==。

有了两个颜色缓冲,我们就有了一个正常场景的图像和一个提取出的亮区的图像;这些都在一个渲染步骤中完成。

image

有了一个提取出的亮区图像,我们现在就要把这个图像进行模糊处理。我们可以使用帧缓冲教程后处理部分的那个简单的盒子过滤器,但不过我们最好还是使用一个更高级的更漂亮的模糊过滤器:**高斯模糊(Gaussian blur)**。

高斯模糊

在后处理教程那里,我们采用的模糊是一个图像中所有周围像素的均值,它的确为我们提供了一个简易实现的模糊,但是效果并不好。

高斯模糊基于高斯曲线,高斯曲线通常被描述为一个钟形曲线,中间的值达到最大化,随着距离的增加,两边的值不断减少。高斯曲线在数学上有不同的形式,但是通常是这样的形状:

image

高斯曲线在它的中间处的面积最大,使用它的值作为权重使得近处的样本拥有最大的优先权。

  • 比如,如果我们从fragment的32×32的四方形区域采样,这个权重随着和fragment的距离变大逐渐减小;通常这会得到更好更真实的模糊效果,这种模糊叫做高斯模糊。

要实现高斯模糊过滤我们需要一个二维四方形作为权重,从这个二维高斯曲线方程中去获取它。

  • 然而这个过程有个问题,就是很快会消耗极大的性能。以一个32×32的模糊kernel为例,我们必须对每个fragment从一个纹理中采样1024次!

幸运的是,高斯方程有个非常巧妙的特性,它允许我们把二维方程分解为两个更小的方程:一个描述水平权重,另一个描述垂直权重。

  • 我们首先用水平权重在整个纹理上进行水平模糊,然后在经改变的纹理上进行垂直模糊。
  • 利用这个特性,结果是一样的,但是可以节省难以置信的性能,因为我们现在只需做32+32次采样,不再是1024了!这叫做两步高斯模糊。
image

这意味着我们如果对一个图像进行模糊处理,==至少需要两步==,最好使用帧缓冲对象做这件事。

具体来说,我们将实现像乒乓球一样的帧缓冲来实现高斯模糊。

  • 它的意思是,有一对儿帧缓冲,我们把另一个帧缓冲的颜色缓冲放进当前的帧缓冲的颜色缓冲中,使用不同的着色效果渲染指定的次数(基本上就是不断地切换帧缓冲和纹理去绘制)。
  • 这样我们先在场景纹理的第一个缓冲中进行模糊,然后在把第一个帧缓冲的颜色缓冲放进第二个帧缓冲进行模糊,接着,将第二个帧缓冲的颜色缓冲放进第一个,循环往复。

在我们研究帧缓冲之前,先讨论高斯模糊的像素着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D image;

uniform bool horizontal;

uniform float weight[5] = float[] (0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);

void main()
{
vec2 tex_offset = 1.0 / textureSize(image, 0); // gets size of single texel
vec3 result = texture(image, TexCoords).rgb * weight[0]; // current fragment's contribution
if(horizontal)
{
for(int i = 1; i < 5; ++i)
{
result += texture(image, TexCoords + vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
result += texture(image, TexCoords - vec2(tex_offset.x * i, 0.0)).rgb * weight[i];
}
}
else
{
for(int i = 1; i < 5; ++i)
{
result += texture(image, TexCoords + vec2(0.0, tex_offset.y * i)).rgb * weight[i];
result += texture(image, TexCoords - vec2(0.0, tex_offset.y * i)).rgb * weight[i];
}
}
FragColor = vec4(result, 1.0);
}

这里我们使用一个比较小的高斯权重做例子,每次我们用它来指定当前fragment的水平或垂直样本的特定权重。

你会发现我们基本上是将模糊过滤器根据我们在uniform变量horizontal设置的值分割为一个水平和一个垂直部分。通过用1.0除以纹理的大小(从textureSize得到一个vec2)得到一个纹理像素的实际大小,以此作为偏移距离的根据。

我们为图像的模糊处理创建==两个基本的帧缓冲==,每个==只有一个颜色缓冲纹理==:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GLuint pingpongFBO[2];
GLuint pingpongBuffer[2];
glGenFramebuffers(2, pingpongFBO);
glGenTextures(2, pingpongBuffer);
for (GLuint i = 0; i < 2; i++)
{
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[i]);
glBindTexture(GL_TEXTURE_2D, pingpongBuffer[i]);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL
);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glFramebufferTexture2D(
GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, pingpongBuffer[i], 0
);
}

得到一个HDR纹理后,我们用提取出来的亮区纹理填充一个帧缓冲,然后对其模糊处理10次(5次垂直5次水平):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GLboolean horizontal = true, first_iteration = true;
GLuint amount = 10;
shaderBlur.Use();
for (GLuint i = 0; i < amount; i++)
{
glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]);
glUniform1i(glGetUniformLocation(shaderBlur.Program, "horizontal"), horizontal);
glBindTexture(
GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongBuffers[!horizontal]
);
RenderQuad();
horizontal = !horizontal;
if (first_iteration)
first_iteration = false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

每次循环我们根据我们打算渲染的是水平还是垂直来绑定两个缓冲其中之一,而将另一个绑定为纹理进行模糊。

第一次迭代,因为两个颜色缓冲都是空的所以我们随意绑定一个去进行模糊处理。重复这个步骤10次,亮区图像就进行一个重复5次的高斯模糊了。

这样我们可以对任意图像进行任意次模糊处理;高斯模糊循环次数越多,模糊的强度越大。

通过对提取亮区纹理进行5次模糊,我们就得到了一个正确的模糊的场景亮区图像。

image

泛光的最后一步是把模糊处理的图像和场景原来的HDR纹理进行结合。

把两个纹理混合

有了场景的HDR纹理和模糊处理的亮区纹理,我们只需把它们结合起来就能实现泛光或称光晕效果了。最终的像素着色器(大部分和HDR教程用的差不多)要把两个纹理混合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D scene;
uniform sampler2D bloomBlur;
uniform float exposure;

void main()
{
const float gamma = 2.2;
vec3 hdrColor = texture(scene, TexCoords).rgb;
vec3 bloomColor = texture(bloomBlur, TexCoords).rgb;
hdrColor += bloomColor; // additive blending
// tone mapping
vec3 result = vec3(1.0) - exp(-hdrColor * exposure);
// also gamma correct while we're at it
result = pow(result, vec3(1.0 / gamma));
FragColor = vec4(result, 1.0f);
}

要注意的是我们要在应用色调映射之前添加泛光效果。这样添加的亮区的泛光,也会柔和转换为LDR,光照效果相对会更好。

把两个纹理结合以后,场景亮区便有了合适的光晕特效:

image

有颜色的立方体看起来仿佛更亮,它向外发射光芒,的确是一个更好的视觉效果。

这个场景比较简单,所以泛光效果不算十分令人瞩目,但在更好的场景中合理配置之后效果会有巨大的不同。


这个教程我们只是用了一个相对简单的高斯模糊过滤器,它在每个方向上只有5个样本。通过沿着更大的半径或重复更多次数的模糊,进行采样我们就可以提升模糊的效果。

因为模糊的质量与泛光效果的质量正相关,提升模糊效果就能够提升泛光效果。

  • 有些提升将模糊过滤器与不同大小的模糊kernel或采用多个高斯曲线来选择性地结合权重结合起来使用。

附加资源

5.8 延迟着色法

我们现在一直使用的光照方式叫做**正向渲染(Forward Rendering)或者正向着色法(Forward Shading)**,它是我们渲染物体的一种非常直接的方式,在场景中我们根据所有光源照亮一个物体,之后再渲染下一个物体,以此类推。

它非常容易理解,也很容易实现,但是同时它对程序性能的影响也很大,因为对于每一个需要渲染的物体,程序都要对==每一个光源每一个需要渲染的片段==进行迭代,这是非常多的!

因为大部分片段着色器的输出都会被之后的输出覆盖,正向渲染还会在场景中因为高深的复杂度(多个物体==重合==在一个像素上)浪费大量的片段着色器运行时间。


**延迟着色法(Deferred Shading)或者说是延迟渲染(Deferred Rendering)**,为了解决上述问题而诞生了,它大幅度地改变了我们渲染物体的方式。这给我们优化拥有大量光源的场景提供了很多的选择,因为它能够在渲染上百甚至上千光源的同时还能够保持能让人接受的帧率。下面这张图片包含了一共1874个点光源,它是使用延迟着色法来完成的,而这对于正向渲染几乎是不可能的(图片来源:Hannes Nevalainen)。

image

延迟着色法基于我们**延迟(Defer)推迟(Postpone)**大部分计算量非常大的渲染(像是光照)到后期进行处理的想法。

它包含两个处理阶段(Pass):

一阶段

在第一个几何处理阶段(Geometry Pass)中,我们先渲染场景一次,之后获取对象的各种几何信息,并储存在一系列叫做G缓冲(G-buffer)的纹理中;

  • 想想位置向量(Position Vector)、颜色向量(Color Vector)、法向量(Normal Vector)和/或镜面值(Specular Value)。

  • 场景中这些储存在G缓冲中的几何信息将会在之后用来做(更复杂的)光照计算。下面是一帧中G缓冲的内容:

    image

二阶段

我们会在第二个光照处理阶段(Lighting Pass)中使用G缓冲内的纹理数据。

在光照处理阶段中,我们渲染一个屏幕大小的方形,并使用G缓冲中的几何数据对每一个片段计算场景的光照;在每个像素中我们都会对G缓冲进行迭代。

我们对于渲染过程进行解耦,将它高级的片段处理挪到后期进行,而不是直接将每个对象从顶点着色器带到片段着色器。光照计算过程还是和我们以前一样,但是现在我们需要==从对应的G缓冲==而不是顶点着色器(和一些uniform变量)那里==获取输入变量==了。


下面这幅图片很好地展示了延迟着色法的整个过程:

image

优点:

  • 这种渲染方法一个很大的好处就是能保证在G缓冲中的片段和在屏幕上呈现的像素所包含的片段信息是一样的,因为深度测试已经最终将这里的片段信息作为最顶层的片段。这样保证了对于在光照处理阶段中处理的每一个像素都只处理一次,所以我们能够省下很多无用的渲染调用。
  • 除此之外,延迟渲染还允许我们做更多的优化,从而渲染更多的光源。

缺点:

  • 当然这种方法也带来几个缺陷, 由于G缓冲要求我们在纹理颜色缓冲中存储相对比较大的场景数据,这会消耗比较多的显存,尤其是类似位置向量之类的需要高精度的场景数据。
  • 另外一个缺点就是他不支持透明度混合(因为我们只有最前面的片段信息), 也不能使用MSAA了。

针对这几个问题我们可以做一些变通来克服这些缺点,这些我们留会在教程的最后讨论。

在几何处理阶段中填充G缓冲非常高效,因为我们直接储存像素位置,颜色或者是法线等对象信息到帧缓冲中,而这几乎不会消耗处理时间。在此基础上使用多渲染目标(Multiple Render Targets, MRT)技术,我们甚至可以在一个渲染处理之内完成这所有的工作。

1.G缓冲

本质:帧缓冲的颜色缓冲

G缓冲(G-buffer)是对所有用来==储存光照相关的数据==,并在最后的光照处理阶段中使用的所有纹理的总称。趁此机会,让我们顺便复习一下在正向渲染中照亮一个片段所需要的所有数据:

  • 一个3D位置向量来计算(插值)片段位置变量lightDirviewDir使用
  • 一个RGB漫反射颜色向量,也就是反照率(Albedo)
  • 一个3D向量来判断平面的斜率
  • 一个镜面强度(Specular Intensity)浮点值
  • 所有光源的位置和颜色向量
  • 玩家或者观察者的位置向量

有了这些(逐片段)变量的处置权,我们就能够计算我们很熟悉的(布林-)冯氏光照(Blinn-Phong Lighting)了。

光源的位置,颜色,和玩家的观察位置可以通过uniform变量来设置,但是其它变量对于每个对象的片段都是不同的。

如果我们能以某种方式传输完全相同的数据到最终的延迟光照处理阶段中,我们就能计算与之前相同的光照效果了,尽管我们只是在渲染一个2D方形的片段。

延迟着色流程概览

OpenGL并没有限制我们能在纹理中能存储的东西,所以现在你应该清楚在一个或多个屏幕大小的纹理中储存所有逐片段数据并在之后光照处理阶段中使用的可行性了。

因为G缓冲纹理将会和光照处理阶段中的2D方形一样大,我们会获得和正向渲染设置完全一样的片段数据,但在光照处理阶段这里是一对一映射。

整个过程在伪代码中会是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
while(...) // 游戏循环
{
// 1. 几何处理阶段:渲染所有的几何/颜色数据到G缓冲
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
gBufferShader.Use();
for(Object obj : Objects)
{
ConfigureShaderTransformsAndUniforms();
obj.Draw();
}
// 2. 光照处理阶段:使用G缓冲计算场景的光照
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClear(GL_COLOR_BUFFER_BIT);
lightingPassShader.Use();
BindAllGBufferTextures();
SetLightingUniforms();
RenderQuad();
}

对于每一个片段我们需要储存的数据有:一个位置向量、一个向量,一个颜色向量,一个镜面强度值

所以我们在几何处理阶段中需要渲染场景中所有的对象并储存这些数据分量到G缓冲中。

我们可以再次使用**多渲染目标(Multiple Render Targets)**来在一个渲染处理之内渲染多个颜色缓冲,在之前的[泛光教程](https://learnopengl-cn.github.io/05 Advanced Lighting/07 Bloom/)中我们也简单地提及了它。

填充G-Buffer

对于几何渲染处理阶段,我们首先需要初始化一个帧缓冲对象,我们很直观的称它为gBuffer,它包含了多个颜色缓冲和一个单独的深度渲染缓冲对象(Depth Renderbuffer Object)。

对于位置和法向量的纹理,我们希望使用高精度的纹理(每分量16或32位的浮点数),而对于反照率和镜面值,使用默认的纹理(每分量8位浮点数)就够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
GLuint gBuffer;
glGenFramebuffers(1, &gBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
GLuint gPosition, gNormal, gColorSpec;

// - 位置颜色缓冲
glGenTextures(1, &gPosition);
glBindTexture(GL_TEXTURE_2D, gPosition);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, gPosition, 0

// - 法线颜色缓冲
glGenTextures(1, &gNormal);
glBindTexture(GL_TEXTURE_2D, gNormal);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, gNormal, 0);

// - 颜色 + 镜面颜色缓冲
glGenTextures(1, &gAlbedoSpec);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, gAlbedoSpec, 0);

// - 告诉OpenGL我们将要使用(帧缓冲的)哪种颜色附件来进行渲染
GLuint attachments[3] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2 };
glDrawBuffers(3, attachments);

// 之后同样添加渲染缓冲对象(Render Buffer Object)为深度缓冲(Depth Buffer),并检查完整性
[...]

由于我们使用了多渲染目标,我们需要显式告诉OpenGL我们需要使用glDrawBuffers渲染的是和GBuffer关联的哪个颜色缓冲。

同样需要注意的是,我们使用RGB纹理来储存位置和法线的数据,因为每个对象只有三个分量;但是我们将颜色和镜面强度数据合并到一起,存储到一个单独的RGBA纹理里面,这样我们就不需要声明一个额外的颜色缓冲纹理了。

随着你的延迟渲染管线变得越来越复杂,需要更多的数据的时候,你就会很快发现新的方式来组合数据到一个单独的纹理当中。


接下来我们需要渲染它们到G缓冲中。假设每个对象都有漫反射,一个法线和一个镜面强度纹理,我们会想使用一些像下面这个片段着色器的东西来渲染它们到G缓冲中去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
layout (location = 0) out vec3 gPosition;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;

void main()
{
// 存储第一个G缓冲纹理中的片段位置向量
gPosition = FragPos;
// 同样存储对每个逐片段法线到G缓冲中
gNormal = normalize(Normal);
// 和漫反射对每个逐片段颜色
gAlbedoSpec.rgb = texture(texture_diffuse1, TexCoords).rgb;
// 存储镜面强度到gAlbedoSpec的alpha分量
gAlbedoSpec.a = texture(texture_specular1, TexCoords).r;
}

因为我们使用了多渲染目标,这个布局指示符(Layout Specifier)告诉了OpenGL我们需要渲染到当前的活跃帧缓冲中的哪一个颜色缓冲。

注意我们并没有储存镜面强度到一个单独的颜色缓冲纹理中,因为我们可以储存它单独的浮点值到其它颜色缓冲纹理的alpha分量中。

请记住,因为有光照计算,所以保证所有变量在一个坐标空间当中至关重要。在这里我们在==世界空间==中存储(并计算)所有的变量。

如果我们现在想要渲染一大堆纳米装战士对象到gBuffer帧缓冲中,并通过一个一个分别投影它的颜色缓冲到铺屏四边形中尝试将他们显示出来,我们会看到向下面这样的东西:

image

尝试想象世界空间位置和法向量都是正确的。比如说,指向右侧的法向量将会被更多地对齐到红色上,从场景原点指向右侧的位置矢量也同样是这样。一旦你对G缓冲中的内容满意了,我们就该进入到下一步:光照处理阶段了。

2.延迟光照处理阶段

现在我们已经有了一大堆的片段数据储存在G缓冲中供我们处置,我们可以选择通过一个像素一个像素地遍历各个G缓冲纹理,并将储存在它们里面的内容作为光照算法的输入,来完全计算场景最终的光照颜色。

由于所有的G缓冲纹理都代表的是最终变换的片段值,我们只需要对每一个像素执行一次昂贵的光照运算就行了。这使得延迟光照非常高效,特别是在需要调用大量重型片段着色器的复杂场景中。

对于这个光照处理阶段,我们将会渲染一个2D全屏的方形(有一点像后期处理效果)并且在每个像素上运行一个昂贵的光照片段着色器。

1
2
3
4
5
6
7
8
9
10
11
12
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPosition);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, gAlbedoSpec);
// 同样发送光照相关的uniform
SendAllLightUniformsToShader(shaderLightingPass);
glUniform3fv(glGetUniformLocation(shaderLightingPass.Program, "viewPos"), 1, &camera.Position[0]);
RenderQuad();

我们在渲染之前绑定了G缓冲中所有相关的纹理,并且发送光照相关的uniform变量到着色器中。


光照处理阶段的片段着色器和我们之前一直在用的光照教程着色器是非常相似的,除了我们添加了一个新的方法,从而使我们能够获取光照的输入变量,当然这些变量我们会从G缓冲中直接采样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D gPosition;
uniform sampler2D gNormal;
uniform sampler2D gAlbedoSpec;

struct Light {
vec3 Position;
vec3 Color;
};
const int NR_LIGHTS = 32;
uniform Light lights[NR_LIGHTS];
uniform vec3 viewPos;

void main()
{
// 从G缓冲中获取数据
vec3 FragPos = texture(gPosition, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Albedo = texture(gAlbedoSpec, TexCoords).rgb;
float Specular = texture(gAlbedoSpec, TexCoords).a;

// 然后和往常一样地计算光照
vec3 lighting = Albedo * 0.1; // 硬编码环境光照分量
vec3 viewDir = normalize(viewPos - FragPos);
for(int i = 0; i < NR_LIGHTS; ++i)
{
// 漫反射
vec3 lightDir = normalize(lights[i].Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Albedo * lights[i].Color;
lighting += diffuse;
}

FragColor = vec4(lighting, 1.0);
}

光照处理阶段着色器接受三个uniform纹理,代表G缓冲,它们包含了我们在几何处理阶段储存的所有数据。如果我们现在再使用当前片段的纹理坐标采样这些数据,我们将会获得和之前完全一样的片段值,这就像我们在直接渲染几何体。

在片段着色器的一开始,我们通过一个简单的纹理查找从G缓冲纹理中获取了光照相关的变量。注意我们从gAlbedoSpec纹理中同时获取了Albedo颜色和Spqcular强度。

因为我们现在已经有了必要的逐片段变量(和相关的uniform变量)来计算布林-冯氏光照(Blinn-Phong Lighting),我们不需要对光照代码做任何修改了。我们在延迟着色法中唯一需要改的就是获取光照输入变量的方法。

运行一个包含32个小光源的简单Demo会是像这样子的:

image

延迟着色法的其中一个缺点就是它不能进行[混合](https://learnopengl-cn.github.io/04 Advanced OpenGL/03 Blending/)(Blending),因为G缓冲中所有的数据都是从一个单独的片段中来的,而混合需要对多个片段的组合进行操作。

延迟着色法另外一个缺点就是它迫使你对大部分场景的光照使用相同的光照算法,你可以通过包含更多关于材质的数据到G缓冲中来减轻这一缺点。(但这样显存也就大了)。

为了克服这些缺点(特别是混合),我们通常分割我们的渲染器为==两个部分==:一个是延迟渲染的部分,另一个是专门为了混合或者其他不适合延迟渲染管线的着色器效果而设计的的正向渲染的部分。

为了展示这是如何工作的,我们将会使用正向渲染器渲染光源为一个小立方体,因为光照立方体会需要一个特殊的着色器(会输出一个光照颜色)。

3.结合延迟渲染与正向渲染

现在我们想要渲染每一个光源为一个3D立方体,并放置在光源的位置上随着延迟渲染器一起发出光源的颜色。

很明显,我们需要做的第一件事就是在延迟渲染方形之上正向渲染所有的光源,它会在==延迟渲染管线的最后进行==。所以我们只需要像正常情况下渲染立方体,只是会在我们完成延迟渲染操作之后进行。

代码会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 延迟渲染光照渲染阶段
[...]
RenderQuad();

// 现在像正常情况一样正向渲染所有光立方体
shaderLightBox.Use();
glUniformMatrix4fv(locProjection, 1, GL_FALSE, glm::value_ptr(projection));
glUniformMatrix4fv(locView, 1, GL_FALSE, glm::value_ptr(view));
for (GLuint i = 0; i < lightPositions.size(); i++)
{
model = glm::mat4();
model = glm::translate(model, lightPositions[i]);
model = glm::scale(model, glm::vec3(0.25f));
glUniformMatrix4fv(locModel, 1, GL_FALSE, glm::value_ptr(model));
glUniform3fv(locLightcolor, 1, &lightColors[i][0]);
RenderCube();
}

然而,这些渲染出来的立方体并没有考虑到我们储存的延迟渲染器的几何深度(Depth)信息,并且结果是它被渲染在之前渲染过的物体之上,这并不是我们想要的结果。

image

我们需要做的就是首先==复制==出在==几何渲染阶段中储存的深度信息==,并输出到默认的帧缓冲的深度缓冲,然后我们才渲染光立方体。这样之后只有当它在之前渲染过的几何体前方的时候,光立方体的片段才会被渲染出来。

我们可以使用glBlitFramebuffer复制一个帧缓冲的内容到另一个帧缓冲中,这个函数我们也在[抗锯齿](http://learnopengl-cn.readthedocs.org/zh/latest/04 Advanced OpenGL/11 Anti Aliasing/)的教程中使用过,用来还原多重采样的帧缓冲。glBlitFramebuffer这个函数允许我们复制一个用户定义的帧缓冲区域到另一个用户定义的帧缓冲区域。

我们储存所有延迟渲染阶段中所有物体的深度信息在gBuffer这个FBO中。如果我们仅仅是简单复制它的深度缓冲内容到默认帧缓冲的深度缓冲中,那么光立方体就会像是场景中所有的几何体都是正向渲染出来的一样渲染出来。

就像在抗锯齿教程中介绍的那样,我们需要指定一个帧缓冲为读帧缓冲(Read Framebuffer),并且类似地指定一个帧缓冲为写帧缓冲(Write Framebuffer):

1
2
3
4
5
6
7
8
glBindFramebuffer(GL_READ_FRAMEBUFFER, gBuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0); // 写入到默认帧缓冲
glBlitFramebuffer(
0, 0, SCR_WIDTH, SCR_HEIGHT, 0, 0, SCR_WIDTH, SCR_HEIGHT, GL_DEPTH_BUFFER_BIT, GL_NEAREST
);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 现在像之前一样渲染光立方体
[...]

在这里我们复制整个读帧缓冲的深度缓冲信息到默认帧缓冲的深度缓冲,对于颜色缓冲和模板缓冲我们也可以这样处理。

现在如果我们接下来再渲染光立方体,场景里的几何体将会看起来很真实了,而不只是简单地粘贴立方体到2D方形之上:

image

有了这种方法,我们就能够轻易地结合延迟着色法和正向着色法了。这真是太棒了,我们现在可以应用混合或者渲染需要特殊着色器效果的物体了,这在延迟渲染中是不可能做到的。

4.更多的光源

延迟渲染一直被称赞的原因就是它能够渲染大量的光源而不消耗大量的性能。然而,延迟渲染它本身并不能支持非常大量的光源,因为我们仍然必须要对场景中每一个光源计算每一个片段的光照分量。真正让大量光源成为可能的是我们能够对延迟渲染管线引用的一个非常棒的优化:光体积(Light Volumes)

通常情况下,当我们渲染一个复杂光照场景下的片段着色器时,我们会计算场景中每一个光源的贡献,不管它们离这个片段有多远。很大一部分的光源根本就不会到达这个片段,所以为什么我们还要浪费这么多光照运算呢?

隐藏在光体积背后的想法就是计算==光源的半径==,或是==体积==,也就是==光能够到达片段的范围==。由于大部分光源都使用了某种形式的衰减(Attenuation),我们可以用它来计算光源能够到达的最大路程,或者说是半径。

我们接下来只需要对那些在==一个或多个光体积内的片段==进行繁重的光照运算就行了。这可以给我们省下来很可观的计算量,因为我们现在只在需要的情况下计算光照。

这个方法的难点基本就是找出一个光源光体积的大小,或者是半径。

计算一个光源的体积或半径

为了获取一个光源的体积半径,我们需要解一个对于一个我们认为是**黑暗(Dark)**的亮度(Brightness)的衰减方程,它可以是0.0,或者是更亮一点的但仍被认为黑暗的值,像是0.03。为了展示我们如何计算光源的体积半径,我们将会使用一个在[投光物](http://learnopengl-cn.readthedocs.org/zh/latest/02 Lighting/05 Light casters/)这节中引入的一个更加复杂,但非常灵活的衰减方程:

image

我们现在想要在$F_{light}$等于0的前提下解这个方程,也就是说光在该距离完全是黑暗的。然而这个方程永远不会真正等于0.0,所以它没有解。所以,我们不会求表达式等于0.0时候的解,相反我们会求当亮度值靠近于0.0的解,这时候它还是能被看做是黑暗的。

在这个教程的演示场景中,我们选择$\frac{5}{256}$作为一个合适的光照值;除以256是因为默认的8-bit帧缓冲可以每个分量显示这么多强度值(Intensity)。

我们使用的衰减方程在它的可视范围内基本都是黑暗的,所以如果我们想要限制它为一个比5/2565/256更加黑暗的亮度,光体积就会变得太大从而变得低效。

只要是用户不能在光体积边缘看到一个突兀的截断,这个参数就没事了。当然它还是依赖于场景的类型,一个高的亮度阀值会产生更小的光体积,从而获得更高的效率,然而它同样会产生一个很容易发现的副作用,那就是光会在光体积边界看起来突然断掉。

我们要求的衰减方程会是这样:

image

在这里,$I_{max}$是光源最亮的颜色分量。我们之所以使用光源最亮的颜色分量是因为解光源最亮的强度值方程最好地反映了理想光体积半径。

image

它给我们了一个通用公式从而允许我们计算x的值,即光源的光体积半径,只要我们提供了一个常量,线性和二次项参数:

1
2
3
4
5
6
7
GLfloat constant  = 1.0; 
GLfloat linear = 0.7;
GLfloat quadratic = 1.8;
GLfloat lightMax = std::fmaxf(std::fmaxf(lightColor.r, lightColor.g), lightColor.b);
GLfloat radius =
(-linear + std::sqrtf(linear * linear - 4 * quadratic * (constant - (256.0 / 5.0) * lightMax)))
/ (2 * quadratic);

它会返回一个大概在1.0到5.0范围内的半径值,它取决于光的最大强度。


对于场景中每一个光源,我们都计算它的半径,并仅在片段在光源的体积内部时才计算该光源的光照。

下面是更新过的光照处理阶段片段着色器,它考虑到了计算出来的光体积。注意这种方法仅仅用作教学目的,在实际场景中是不可行的,我们会在后面讨论它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Light {
[...]
float Radius;
};

void main()
{
[...]
for(int i = 0; i < NR_LIGHTS; ++i)
{
// 计算光源和该片段间距离
float distance = length(lights[i].Position - FragPos);
if(distance < lights[i].Radius)
{
// 执行大开销光照
[...]
}
}
}

这次的结果和之前一模一样,但是这次物体只对所在光体积的光源计算光照。

5.真正使用光体积

本节只浅浅谈了一下光体积,没给出进一步解释和示例,因此只能作为了解

上面那个片段着色器在实际情况下不能真正地工作,它只演示了我们可以如何使用光体积减少光照运算。

然而事实上,你的GPU和GLSL并不擅长优化循环和分支。这一缺陷的原因是GPU中着色器的运行是==高度并行==的,大部分的架构要求对于一个大的线程集合,GPU需要对它运行完全一样的着色器代码从而获得高效率。

这通常意味着一个着色器运行时总是执行一个if语句所有的分支从而保证着色器运行都是一样的,这使得我们之前的半径检测优化完全变得==无用==,我们==仍然在对所有光源计算光照==!


使用光体积更好的方法是渲染一个实际的球体,并根据光体积的半径缩放。这些球的中心放置在光源的位置,由于它是根据光体积半径缩放的,这个球体正好覆盖了光的可视体积。

这就是我们的技巧:我们使用大体相同的延迟片段着色器来渲染球体。

因为==球体产生了完全匹配于受影响像素的着色器调用,我们只渲染了受影响的像素而跳过其它的像素==。下面这幅图展示了这一技巧:

image

它被应用在场景中每个光源上,并且所得的片段相加混合在一起。这个结果和之前场景是一样的,但这一次只渲染对于光源相关的片段。

它有效地减少了从nr_objects * nr_lightsnr_objects + nr_lights的计算量,这使得多光源场景的渲染变得无比高效。这正是为什么延迟渲染非常适合渲染很大数量光源。

然而这个方法仍然有一个问题:面剔除(Face Culling)需要被启用(否则我们会渲染一个光效果两次),并且在它启用的时候用户可能进入一个光源的光体积,然而这样之后这个体积就不再被渲染了(由于背面剔除),这会使得光源的影响消失。这个问题可以通过一个模板缓冲技巧来解决。

渲染光体积确实会带来沉重的性能负担,虽然它通常比普通的延迟渲染更快,这仍然不是最好的优化。

另外两个基于延迟渲染的更流行(并且更高效)的拓展叫做**延迟光照(Deferred Lighting)切片式延迟着色法(Tile-based Deferred Shading)**。这些方法会很大程度上提高大量光源渲染的效率,并且也能允许一个相对高效的多重采样抗锯齿(MSAA)。然而受制于这篇教程的长度,我将会在之后的教程中介绍这些优化。

6.延迟渲染 vs 正向渲染

仅仅是延迟着色法它本身(没有光体积)已经是一个很大的优化了,每个像素仅仅运行一个单独的片段着色器,然而对于正向渲染,我们通常会对一个像素运行多次片段着色器。

  • 当然,延迟渲染确实带来一些缺点:大内存开销,没有MSAA和混合(仍需要正向渲染的配合)。

当你有一个很小的场景并且没有很多的光源时候,延迟渲染并不一定会更快一点,甚至有些时候由于开销超过了它的优点还会更慢。

然而在一个更复杂的场景中,延迟渲染会快速变成一个重要的优化,特别是有了更先进的优化拓展的时候。

最后我仍然想指出,基本上所有能通过正向渲染完成的效果能够同样在延迟渲染场景中实现,这通常需要一些小的翻译步骤。

  • 举个例子,如果我们想要在延迟渲染器中使用法线贴图(Normal Mapping),我们需要改变几何渲染阶段着色器来输出一个世界空间法线(World-space Normal),它从法线贴图中提取出来(==使用一个TBN矩阵==)而不是表面法线,光照渲染阶段中的光照运算一点都不需要变。
  • 如果你想要让视差贴图工作,首先你需要在采样一个物体的漫反射,镜面,和法线纹理之前首先置换几何渲染阶段中的纹理坐标。一旦你了解了延迟渲染背后的理念,变得有创造力并不是什么难事。

7.附加资源

5.9 SSAO

1.原理

我们已经在前面的基础教程中简单介绍到了这部分内容:环境光照(Ambient Lighting)。环境光照是我们加入场景总体光照中的一个固定光照常量,它被用来模拟光的**散射(Scattering)**。

在现实中,光线会以==任意方向==散射,它的强度是会一直改变的,所以间接被照到的那部分场景也应该有变化的强度,而不是一成不变的环境光。

其中一种间接光照的模拟叫做**环境光遮蔽(Ambient Occlusion)**,它的==原理==是通过将褶皱、孔洞和非常靠近的墙面==变暗==的方法近似模拟出间接光照(个人:这些地方受到的散射会更少)。

  • 站起来看一看你房间的拐角或者是褶皱,是不是这些地方会看起来有一点暗?

下面这幅图展示了在使用和不使用SSAO时场景的不同。特别注意对比褶皱部分,你会发现(环境)光被遮蔽了许多:

image

尽管这不是一个非常明显的效果,启用SSAO的图像确实给我们更真实的感觉,这些小的遮蔽细节给整个场景带来了更强的深度感。


环境光遮蔽这一技术会带来很大的性能开销,因为它还需要考虑周围的几何体。我们可以对空间中每一点发射大量光线来确定其遮蔽量,但是这在实时运算中会很快变成大问题。

在2007年,Crytek公司发布了一款叫做**==屏幕空间环境光遮蔽==(Screen-Space Ambient Occlusion, SSAO)**的技术,并用在了他们的看家作孤岛危机上。这一技术使用了==屏幕空间场景的深度==而不是真实的几何体数据来==确定遮蔽量==。这一做法相对于真正的环境光遮蔽不但速度快,而且还能获得很好的效果,使得它成为近似实时环境光遮蔽的标准。

SSAO背后的==原理==很简单:对于铺屏四边形(Screen-filled Quad)上的每一个片段,我们都会根据周边深度值计算一个**遮蔽因子(Occlusion Factor)**这个遮蔽因子之后会被用来减少或者抵消片段的环境光照分量。

  • 遮蔽因子是通过采集片段周围球型核心(Kernel)的多个深度样本,并和当前片段深度值对比而得到的。==高于==片段深度值(解释:采样点深度值 > 采样点所在片段采样得到的深度值)样本的个数就是我们想要的遮蔽因子。
image

上图中在几何体内灰色的深度样本都是==高于==片段深度值的,他们会增加遮蔽因子;几何体内样本个数越多,片段获得的环境光照也就越少。

image

从辐射度量学角度分析(Games101)也好理解:

  • 环境光的计算是来自四面八方的,如果有部分地方被遮挡,显然能量就低了,低了就暗了。

很明显,渲染效果的质量和精度与我们采样的样本数量有直接关系。如果样本数量太低,渲染的精度会急剧减少,我们会得到一种叫做**波纹(Banding)**的效果;如果它太高了,反而会影响性能。

我们可以通过引入随机性到采样核心(Sample Kernel)的采样中从而减少样本的数目。通过随机旋转采样核心,我们能在有限样本数量中得到高质量的结果。

然而这仍然会有一定的麻烦,因为随机性引入了一个很明显的噪声图案,我们将需要通过模糊结果来修复这一问题。下面这幅图片(John Chapman的佛像)展示了波纹效果还有随机性造成的效果:

image

你可以看到,尽管我们在低样本数的情况下得到了很明显的波纹效果,引入随机性之后这些波纹效果就完全消失了。


Crytek公司开发的SSAO技术会产生一种特殊的视觉风格。因为使用的采样核心是一个球体,它导致平整的墙面也会显得灰蒙蒙的,因为核心中一半的样本都会在墙这个几何体上。下面这幅图展示了孤岛危机的SSAO,它清晰地展示了这种灰蒙蒙的感觉:

image

由于这个原因,我们将不会使用球体的采样核心,而使用一个沿着表面法向量的半球体采样核心。

image

通过在**法向半球体(Normal-oriented Hemisphere)**周围采样,我们将不会考虑到片段底部的几何体.它消除了环境光遮蔽灰蒙蒙的感觉,从而产生更真实的结果。这个SSAO教程将会基于法向半球法和John Chapman出色的SSAO教程

补.流程概览(个人)

整个SSAO的流程相对来说较为复杂,这里先做一个简单的概述:

  1. 正常渲染场景,在观察空间下数据收集。将场景在观察空间的位置(gPosition)、法线(gNormal)和漫反射颜色(gAlbedo)渲染到G-buffer中,供后续SSAO着色器使用。(==注意==:观察矩阵会将相机放置在原点,并且默认相机朝向负Z轴方向。这个负号卡了我一天!!!)
  2. 随机噪声构建。从cpu传给SSAO着色器。
    • 一个片段的随机量。使用CPU在切线空间生成随机方向向量( xy ∈ [-1, 1], z ∈ [0, 1]),该向量用于观察空间在半球面上的随机采样。使用uniform数组的方式传递。
    • 不同片段的随机量。使用CPU在切线空间生成随机切线向量(xy ∈ [-1, 1], z = 0),该向量用于扰动切线空间切线和副切线。使用纹理的方式传递。
  3. 编写SSAO着色器(附加在屏幕面片上,属屏幕空间)。
    • 取随机切线向量,逐像素构造扰动后的TBN矩阵,将片段从切线空间变为观察空间。
    • 取随机方向向量,设定半径,对观察空间该点位置进行偏移,得到观察空间半球上的一个采样点。记录该==采样点深度==(观察空间)。
    • 将采样点从观察空间变换到屏幕空间,再次采样gPosition纹理,取z分量,得到==采样点位置处的深度值==(观察空间)。
    • 对采样做限制,只对样本中心邻近区域有效。float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
    • 采样点采样的深度值 >= 采样点深度,遮挡因子+1(这里由于观察空间是朝-z轴,因此越大深度越靠近相机)occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
    • 输出 1 - 遮挡因子 / 该片段采样点数量到SSAO贴图中
  4. 模糊SSAO贴图
  5. 延迟着色

验证观察空间是朝-z轴的

1
occlusion += (sampleDepth >= samplePos.z + bias ? 1.0 : 0.0) * rangeCheck;
image
1
occlusion += (abs(sampleDepth) <= abs(samplePos.z + bias) ? 1.0 : 0.0) * rangeCheck;
image

2.样本缓冲

目标:获取生成SSAO贴图的相关数据,存如G-buffer(观察空间)

SSAO需要获取几何体的信息,因为我们需要一些方式来确定一个片段的遮蔽因子。对于每一个片段,我们将需要这些数据:

  • 逐片段位置向量
  • 逐片段的法线向量
  • 逐片段的反射颜色
  • 采样核心
  • 用来旋转采样核心的随机旋转矢量

通过使用一个逐片段观察空间位置,我们可以将一个采样半球核心对准片段的观察空间表面法线。

对于每一个核心样本我们会采样线性深度纹理来比较结果。采样核心会根据旋转矢量稍微偏转一点;我们所获得的遮蔽因子将会之后用来限制最终的环境光照分量。

image

由于SSAO是一种屏幕空间技巧,我们对铺屏2D四边形上每一个片段计算这一效果;也就是说我们没有场景中几何体的信息。我们能做的只是渲染几何体数据到屏幕空间纹理中,我们之后再会将此数据发送到SSAO着色器中,之后我们就能访问到这些几何体数据了。

如果你看了前面一篇教程,你会发现这和延迟渲染很相似。这也就是说SSAO和延迟渲染能完美地兼容,因为我们已经存位置和法线向量到G缓冲中了。

在这个教程中,我们将会在一个简化版本的延迟渲染器([延迟着色法](https://learnopengl-cn.github.io/05 Advanced Lighting/08 Deferred Shading/)教程中)的基础上实现SSAO,所以如果你不知道什么是延迟着色法,请先读完那篇教程。


由于我们已经有了逐片段位置和法线数据(G缓冲中),我们只需要更新一下几何着色器,让它包含片段的线性深度就行了。回忆我们在深度测试那一节学过的知识,我们可以从gl_FragCoord.z中提取线性深度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#version 330 core
layout (location = 0) out vec4 gPositionDepth;
layout (location = 1) out vec3 gNormal;
layout (location = 2) out vec4 gAlbedoSpec;

in vec2 TexCoords;
in vec3 FragPos;
in vec3 Normal;

const float NEAR = 0.1; // 投影矩阵的近平面
const float FAR = 50.0f; // 投影矩阵的远平面
float LinearizeDepth(float depth)
{
float z = depth * 2.0 - 1.0; // 回到NDC
return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR)); //[near,far]
}

void main()
{
// 储存片段的位置矢量到第一个G缓冲纹理
gPositionDepth.xyz = FragPos;
// 储存线性深度到gPositionDepth的alpha分量
gPositionDepth.a = LinearizeDepth(gl_FragCoord.z);
// 储存法线信息到G缓冲
gNormal = normalize(Normal);
// 和漫反射颜色
gAlbedoSpec.rgb = vec3(0.95);
}

提取出来的线性深度是在观察空间中的,所以之后的运算也是在观察空间中。确保G缓冲中的==位置和法线==都在==观察空间==中(乘上观察矩阵)。观察空间线性深度值之后会被保存在gPositionDepth颜色缓冲的alpha分量中,省得我们再声明一个新的颜色缓冲纹理。

通过一些小技巧来通过深度值重构实际位置值是可能的,Matt Pettineo在他的博客里提到了这一技巧。这一技巧需要在着色器里进行一些计算,但是省了我们在G缓冲中存储位置数据,从而省了很多内存。为了示例的简单,我们将不会使用这些优化技巧,你可以自行探究。

gPositionDepth颜色缓冲纹理被设置成了下面这样:

1
2
3
4
5
6
7
glGenTextures(1, &gPositionDepth);
glBindTexture(GL_TEXTURE_2D, gPositionDepth);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

这给我们了一个线性深度纹理,我们可以用它来对每一个核心样本获取深度值。注意我们把线性深度值存储为了浮点数据;这样从0.1到50.0范围深度值都不会被限制在[0.0, 1.0]之间了(而是[0.1, 50.0])。

  • 如果你不用浮点值存储这些深度数据,确保你首先将值除以FAR来标准化它们,再存储到gPositionDepth纹理中,并在以后的着色器中用相似的方法重建它们。

同样需要注意的是GL_CLAMP_TO_EDGE的纹理封装方法。这保证了我们不会不小心采样到在屏幕空间中纹理默认坐标区域之外的深度值。

接下来我们需要真正的半球采样核心和一些方法来随机旋转它。

3.法向半球

目标:定义一个片段的法向半球内的采样点

计算:使用cpu生成随机采样点(生成在切线空间),再传给SSAO用于观察空间半球范围内采样,达到初步随机

我们需要沿着表面法线方向生成大量的样本。就像我们在这个教程的开始介绍的那样,我们想要生成形成半球形的样本。

由于对每个表面法线方向生成采样核心非常困难,也不合实际,我们将在[切线空间](https://learnopengl-cn.github.io/05 Advanced Lighting/04 Normal Mapping/)(Tangent Space)内生成采样核心,法向量将指向正z方向(这里的切线空间是屏幕面片构成的)。

image

假设我们有一个单位半球,我们可以获得一个拥有最大64样本值的采样核心:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::uniform_real_distribution<float> randomFloats(0.0, 1.0); // random floats between [0.0, 1.0]
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (unsigned int i = 0; i < 64; ++i)
{
glm::vec3 sample(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator)
);
sample = glm::normalize(sample); //随机采样的纹理坐标(实际用x,y采样)
sample *= randomFloats(generator); // 随机强度
ssaoKernel.push_back(sample);
}

我们在切线空间中以-1.0到1.0为范围变换x和y方向,并以0.0和1.0为范围变换样本的z方向(如果以-1.0到1.0为范围,取样核心就变成球型了)。由于采样核心将会沿着表面法线对齐,所得的样本矢量将会在半球里。


手动控制,让点收缩靠近核心样本点吧

目前,所有的样本都是==平均分布==在采样核心里的,但是我们更愿意将更多的注意放在靠近真正片段的遮蔽上,也就是将==核心样本靠近原点分布==。我们可以用一个加速插值函数实现它:

1
2
3
4
5
   float scale = (float)i / 64.0; 
scale = lerp(0.1f, 1.0f, scale * scale);
sample *= scale;
ssaoKernel.push_back(sample);
}

lerp被定义为:

1
2
3
4
GLfloat lerp(GLfloat a, GLfloat b, GLfloat f)
{
return a + f * (b - a);
}

这就给了我们一个大部分样本靠近原点的核心分布。

image

每个核心样本将会被用来偏移观察空间片段位置从而采样周围的几何体。我们在教程开始的时候看到,如果没有变化采样核心,我们将需要大量的样本来获得真实的结果。通过引入一个随机的转动到采样核心中,我们可以很大程度上减少这一数量。

4.随机核心转动

目标:在不消耗大量内存的前提下,让不同片段都有不同的法向半球

计算:使用cpu生成随机旋转向量(生成在切线空间),再传给SSAO用于影响切线空间的切线和副切线(相当于旋转了切线空间的T、B坐标轴),达到进一步随机

通过引入一些随机性到采样核心上,我们可以大大减少获得不错结果所需的样本数量。我们可以对场景中每一个片段创建一个随机旋转向量,但这会很快将内存耗尽。

所以,更好的方法是创建一个小的随机旋转向量纹理平铺在屏幕上。

我们创建一个4x4朝向切线空间平面法线的随机旋转向量数组:

1
2
3
4
5
6
7
8
9
std::vector<glm::vec3> ssaoNoise;
for (GLuint i = 0; i < 16; i++)
{
glm::vec3 noise(
randomFloats(generator) * 2.0 - 1.0,
randomFloats(generator) * 2.0 - 1.0,
0.0f);
ssaoNoise.push_back(noise);
}

由于采样核心是沿着正z方向在切线空间内旋转,我们设定z分量为0.0,从而围绕z轴旋转。


我们接下来创建一个包含随机旋转向量的4x4纹理;记得设定它的封装方法为GL_REPEAT,从而保证它合适地平铺在屏幕上。

1
2
3
4
5
6
7
8
Luint noiseTexture; 
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, 4, 4, 0, GL_RGB, GL_FLOAT, &ssaoNoise[0]); //使用随机数据初始化
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

纹理大小:4x4

分量大小:3

所以,相当于16个不同的随机旋转向量平铺在屏幕上

现在我们有了所有的相关输入数据,接下来我们需要实现SSAO。

5.SSAO着色器

SSAO着色器在2D的铺屏四边形上运行,它对于每一个生成的片段计算遮蔽值(为了在最终的光照着色器中使用)。由于我们需要存储SSAO阶段的结果,我们还需要再创建一个帧缓冲对象:

1
2
3
4
5
6
7
8
9
10
11
GLuint ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
GLuint ssaoColorBuffer;

glGenTextures(1, &ssaoColorBuffer);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBuffer, 0);

由于环境遮蔽的结果是一个灰度值,我们将只需要纹理的红色分量,所以我们将颜色缓冲的内部格式设置为GL_RED

渲染SSAO完整的过程会像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 几何处理阶段: 渲染到G缓冲中
glBindFramebuffer(GL_FRAMEBUFFER, gBuffer);
[...]
glBindFramebuffer(GL_FRAMEBUFFER, 0);

// 使用G缓冲渲染SSAO纹理
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
glClear(GL_COLOR_BUFFER_BIT);
shaderSSAO.Use();
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, gPositionDepth); //G-buffer
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, gNormal); //G-buffer
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, noiseTexture); //CPU生成
SendKernelSamplesToShader(); //CPU生成
glUniformMatrix4fv(projLocation, 1, GL_FALSE, glm::value_ptr(projection));
RenderQuad();
glBindFramebuffer(GL_FRAMEBUFFER, 0);

// 光照处理阶段: 渲染场景光照
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shaderLightingPass.Use();
[...]
glActiveTexture(GL_TEXTURE3);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
[...]
RenderQuad();

shaderSSAO这个着色器将对应G缓冲纹理(包括线性深度),噪声纹理和法向半球核心样本作为输入参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#version 330 core
out float FragColor;
in vec2 TexCoords;

uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D texNoise;

uniform vec3 samples[64];
uniform mat4 projection;

// 屏幕的平铺噪声纹理会根据屏幕分辨率除以噪声大小的值来决定
const vec2 noiseScale = vec2(800.0/4.0, 600.0/4.0); // 屏幕 = 800x600

void main()
{
[...]
}

注意我们这里有一个noiseScale的变量。我们想要将噪声纹理平铺(Tile)在屏幕上,但是由于TexCoords的取值在0.0和1.0之间,texNoise纹理将不会平铺。

所以我们将通过屏幕分辨率除以噪声纹理大小的方式计算TexCoords的缩放大小,并在之后提取相关输入向量的时候使用。

1
2
3
vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
vec3 normal = texture(gNormal, TexCoords).rgb;
vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;

由于我们将texNoise的平铺参数设置为GL_REPEAT,随机的值将会在全屏不断重复。加上fragPognormal向量,我们就有足够的数据来创建一个TBN矩阵,将向量从切线空间变换到观察空间。

1
2
3
vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
vec3 bitangent = cross(normal, tangent);
mat3 TBN = mat3(tangent, bitangent, normal);

顶点法线normal和切线空间法线是一样的,所以顶点法线经过model, view变换后仍和切线空间法线一样。因此可以直接得出切线空间法线在观察空间下的表示,进而计算TBN矩阵。

通过使用一个叫做Gramm-Schmidt处理(Gramm-Schmidt Process)的过程,我们创建了一个正交基(Orthogonal Basis),每一次它都会根据randomVec的值稍微倾斜。

注意因为我们使用了一个随机向量来构造切线向量,我们==没必要有一个恰好沿着几何体表面的TBN矩阵==,也就是不需要逐顶点切线(和双切)向量。

个人理解:这里法线依旧垂直表面,只是切线和副切线是随机的。这样做的好处是,给出相同的切线空间下的向量,在观察空间都会不同的表示。真正实现每个片段上的随机采样。


接下来我们对每个核心样本进行迭代,将样本从切线空间变换到观察空间,将它们加到当前像素位置上,并将片段位置深度与储存在原始深度缓冲中的样本深度进行比较。我们来一步步讨论它:

1
2
3
4
5
6
7
8
9
float occlusion = 0.0;
for(int i = 0; i < kernelSize; ++i)
{
// 获取样本位置
vec3 sample = TBN * samples[i]; // 切线->观察空间
sample = fragPos + sample * radius;

[...]
}

这里的kernelSizeradius变量都可以用来调整效果;在这里我们分别保持他们的默认值为641.0

对于每一次迭代我们首先变换各自样本到观察空间。之后我们会加观察空间核心偏移样本到观察空间片段位置上;最后再用radius乘上偏移样本来增加(或减少)SSAO的有效取样半径。

个人:最终可视化结果就是观察空间该点处的表面,会生成一个半球,在半球内采样sample


接下来我们变换sample到屏幕空间,从而我们可以就像正在直接渲染它的位置到屏幕上一样取样sample的(线性)深度值。由于这个向量目前在观察空间,我们将首先使用projection矩阵uniform变换它到裁剪空间。

1
2
3
4
vec4 offset = vec4(sample, 1.0);
offset = projection * offset; // 观察->裁剪空间
offset.xyz /= offset.w; // 透视划分
offset.xyz = offset.xyz * 0.5 + 0.5; // 变换到0.0 - 1.0的值域

在变量被变换到裁剪空间之后,我们用xyz分量除以w分量进行透视划分。结果所得的标准化设备坐标之后变换到**[0.0, 1.0]**范围以便我们使用它们去取样深度纹理:

1
float sampleDepth = texture(gPositionDepth, offset.xy).z;

我们使用offset向量的xy分量采样线性深度纹理从而获取样本位置从观察者视角的深度值(第一个不被遮蔽的可见片段)。


我们接下来检查样本的当前深度值是否大于存储的深度值,如果是的,添加到最终的贡献因子上。

1
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0);

这并没有完全结束,因为仍然还有一个小问题需要考虑。当检测一个靠近表面边缘的片段时,它将会考虑测试表面之下的表面的深度值;这些值将会(不正确地)影响遮蔽因子。我们可以通过引入一个范围检测从而解决这个问题,正如下图所示(John Chapman的佛像):

image
1
2
float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;

这里我们使用了GLSL的smoothstep函数,它非常光滑地在第一和第二个参数范围内插值了第三个参数。如果深度差因此最终取值在radius之间,它们的值将会光滑地根据下面这个曲线插值在0.0和1.0之间:

image

如果我们使用一个在深度值在radius之外就突然移除遮蔽贡献的硬界限范围检测(Hard Cut-off Range Check),我们将会在范围检测应用的地方看见一个明显的(很难看的)边缘。

最后一步,我们需要将遮蔽贡献根据核心的大小标准化,并输出结果。注意我们用1.0减去了遮蔽因子,以便直接使用遮蔽因子去缩放环境光照分量。

1
2
3
}
occlusion = 1.0 - (occlusion / kernelSize);
FragColor = occlusion;

下面这幅图展示了我们最喜欢的纳米装模型正在打盹的场景,环境遮蔽着色器产生了以下的纹理:

image

可见,环境遮蔽产生了非常强烈的深度感。仅仅通过环境遮蔽纹理我们就已经能清晰地看见模型一定躺在地板上而不是浮在空中。

现在的效果仍然看起来不是很完美,由于重复的噪声纹理再图中清晰可见。为了创建一个光滑的环境遮蔽结果,我们需要模糊环境遮蔽纹理。

6.环境遮蔽模糊

在SSAO阶段和光照阶段之间,我们想要进行模糊SSAO纹理的处理,所以我们又创建了一个帧缓冲对象来储存模糊结果。

1
2
3
4
5
6
7
8
9
GLuint ssaoBlurFBO, ssaoColorBufferBlur;
glGenFramebuffers(1, &ssaoBlurFBO);
glBindFramebuffer(GL_FRAMEBUFFER, ssaoBlurFBO);
glGenTextures(1, &ssaoColorBufferBlur);
glBindTexture(GL_TEXTURE_2D, ssaoColorBufferBlur);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, ssaoColorBufferBlur, 0);

由于平铺的随机向量纹理保持了一致的随机性,我们可以使用这一性质来创建一个简单的模糊着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#version 330 core
in vec2 TexCoords;
out float fragColor;

uniform sampler2D ssaoInput;

void main() {
vec2 texelSize = 1.0 / vec2(textureSize(ssaoInput, 0));
float result = 0.0;
for (int x = -2; x < 2; ++x)
{
for (int y = -2; y < 2; ++y)
{
vec2 offset = vec2(float(x), float(y)) * texelSize;
result += texture(ssaoInput, TexCoords + offset).r;
}
}
fragColor = result / (4.0 * 4.0);
}

这里我们遍历了周围在-2.0和2.0之间的SSAO纹理单元(Texel),采样与噪声纹理维度相同数量的SSAO纹理。我们通过使用返回vec2纹理维度的textureSize,根据纹理单元的真实大小偏移了每一个纹理坐标。我们平均所得的结果,获得一个简单但是有效的模糊效果:

image

这就完成了,一个包含逐片段环境遮蔽数据的纹理;在光照处理阶段中可以直接使用。

7.应用环境遮蔽

应用遮蔽因子到光照方程中极其简单:我们要做的只是==将逐片段环境遮蔽因子乘到光照环境分量上==。如果我们使用上个教程中的Blinn-Phong延迟光照着色器并做出一点修改,我们将会得到下面这个片段着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;

uniform sampler2D gPositionDepth;
uniform sampler2D gNormal;
uniform sampler2D gAlbedo;
uniform sampler2D ssao;

struct Light {
vec3 Position;
vec3 Color;

float Linear;
float Quadratic;
float Radius;
};
uniform Light light;

void main()
{
// 从G缓冲中提取数据
vec3 FragPos = texture(gPositionDepth, TexCoords).rgb;
vec3 Normal = texture(gNormal, TexCoords).rgb;
vec3 Diffuse = texture(gAlbedo, TexCoords).rgb;
float AmbientOcclusion = texture(ssao, TexCoords).r;

// Blinn-Phong (观察空间中)
vec3 ambient = vec3(0.3 * AmbientOcclusion); // 这里我们加上遮蔽因子
vec3 lighting = ambient;
vec3 viewDir = normalize(-FragPos); // Viewpos 为 (0.0.0),在观察空间中
// 漫反射
vec3 lightDir = normalize(light.Position - FragPos);
vec3 diffuse = max(dot(Normal, lightDir), 0.0) * Diffuse * light.Color;
// 镜面
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(Normal, halfwayDir), 0.0), 8.0);
vec3 specular = light.Color * spec;
// 衰减
float dist = length(light.Position - FragPos);
float attenuation = 1.0 / (1.0 + light.Linear * dist + light.Quadratic * dist * dist);
diffuse *= attenuation;
specular *= attenuation;
lighting += diffuse + specular;

FragColor = vec4(lighting, 1.0);
}

(除了将其改到观察空间)对比于之前的光照实现,唯一的真正改动就是场景环境分量与AmbientOcclusion值的乘法。通过在场景中加入一个淡蓝色的点光源,我们将会得到下面这个结果:

image

屏幕空间环境遮蔽是一个可高度自定义的效果,它的效果很大程度上依赖于我们根据场景类型调整它的参数。对所有类型的场景并不存在什么完美的参数组合方式。

一些场景只在小半径情况下工作,又有些场景会需要更大的半径和更大的样本数量才能看起来更真实。

当前这个演示用了64个样本,属于比较多的了,你可以调调更小的核心大小从而获得更好的结果。

一些你可以调整(比如说通过uniform)的参数:核心大小,半径和/或噪声核心的大小。你也可以提升最终的遮蔽值到一个用户定义的幂从而增加它的强度(个人:不应该是限制它的范围?):

1
2
occlusion = 1.0 - (occlusion / kernelSize);       
FragColor = pow(occlusion, power);

多试试不同的场景和不同的参数,来欣赏SSAO的可定制性。

尽管SSAO是一个很微小的效果,可能甚至不是很容易注意到,它在很大程度上增加了合适光照场景的真实性,它也绝对是一个在你工具箱中必备的技术。

附加资源

  • SSAO教程:John Chapman优秀的SSAO教程;本教程很大一部分代码和技巧都是基于他的文章
  • 了解你的SSAO效果:关于提高SSAO特定效果的一篇很棒的文章
  • 深度值重构SSAO:OGLDev的一篇在SSAO之上的拓展教程,它讨论了通过仅仅深度值重构位置矢量,节省了存储开销巨大的位置矢量到G缓冲的过程

Ch6.PBR

6.1 理论

==PBR==,或者用更通俗一些的称呼是指==基于物理的渲染==(Physically Based Rendering),它指的是一些在不同程度上都基于与现实世界的物理原理更相符的基本理论所构成的渲染技术的集合。

正因为基于物理的渲染目的便是为了使用一种更符合物理学规律的方式来模拟光线,因此这种渲染方式与我们原来的Phong或者Blinn-Phong光照算法相比总体上看起来要==更真实==一些。

除了看起来更好些以外,由于它与物理性质非常接近,因此我们(尤其是美术师们)可以直接以物理参数为依据来编写表面材质,而不必依靠粗劣的修改与调整来让光照效果看上去正常。

  • 使用基于物理参数的方法来编写材质还有一个更大的好处,就是不论光照条件如何,这些材质看上去都会是正确的,而在非PBR的渲染管线当中有些东西就不会那么真实了。

虽然如此,基于物理的渲染仍然只是对基于物理原理的现实世界的一种近似,这也就是为什么它被称为基于物理的着色(Physically based Shading) 而非物理着色(Physical Shading)的原因。

判断一种PBR光照模型是否是基于物理的,必须满足以下三个条件(不用担心,我们很快就会了解它们的):

  1. 基于微平面(Microfacet)的表面模型。
  2. 能量守恒。
  3. 应用基于物理的BRDF。

在这次的PBR系列教程之中,我们将会把重点放在最先由迪士尼(Disney)提出探讨并被Epic Games首先应用于实时渲染的PBR方案。他们基于==金属质地工作流==(Metallic Workflow)的方案有非常完备的文献记录,广泛应用于各种流行的引擎之中并且有着非常令人惊叹的视觉效果。完成这次的教程之后我们将会制作出类似于这样的一些东西:

image

请注意这个系列的教程所探讨的内容属于==相当高端的领域==,因此要求读者对OpenGL和着色器光照有较好的理解。读者将会需要这些相关的知识:[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/),[立方体贴图](https://learnopengl-cn.github.io/04 Advanced OpenGL/06 Cubemaps/),[Gamma校正](https://learnopengl-cn.github.io/05 Advanced Lighting/02 Gamma Correction/),[HDR](https://learnopengl-cn.github.io/05 Advanced Lighting/06 HDR/)和[法线贴图](https://learnopengl-cn.github.io/05 Advanced Lighting/04 Normal Mapping/)。我们还会深入探讨一些高等数学的内容,我会尽我所能将相关的概念阐述清楚。

微平面模型

所有的PBR技术都基于微平面理论。这项理论认为,达到微观尺度之后任何平面都可以用被称为微平面(Microfacets)的细小镜面来进行描绘。根据平面粗糙程度的不同,这些细小镜面的取向排列可以相当不一致:

image

产生的效果就是:一个平面越是粗糙,这个平面上的微平面的排列就越混乱。

  • 这些微小镜面这样无序取向排列的影响就是,当我们特指镜面光/镜面反射时,入射光线更趋向于向完全不同的方向发散(Scatter)开来,进而产生出分布范围更广泛的镜面反射。
  • 而与之相反的是,对于一个光滑的平面,光线大体上会更趋向于向同一个方向反射,造成更小更锐利的反射:
image

在微观尺度下,没有任何平面是完全光滑的。然而由于这些微平面已经微小到无法逐像素地继续对其进行区分,因此我们假设一个==粗糙度==(Roughness)参数,然后用统计学的方法来估计微平面的粗糙程度。

我们可以基于一个平面的粗糙度来==计算出众多微平面中,朝向方向沿着某个向量h方向的比例==。这个向量h便是位于光线向量l和视线向量v之间的半程向量(Halfway Vector)。我们曾经在之前的[高级光照](https://learnopengl-cn.github.io/05 Advanced Lighting/01 Advanced Lighting/)教程中谈到过中间向量,它的计算方法如下:

image

微平面的朝向方向与半程向量的方向越是一致,镜面反射的效果就越是强烈越是锐利。通过使用一个介于0到1之间的粗糙度参数,我们就能概略地估算微平面的取向情况了:

image

我们可以看到,较高的粗糙度显示出来的镜面反射的轮廓要更大一些。与之相反,较小的粗糙度显示出的镜面反射轮廓则更小更锐利。

能量守恒

微平面近似法使用了这样一种形式的==能量守恒(==Energy Conservation):出射光线的能量永远==不能超过==入射光线的能量(发光面除外)。

如上图我们可以看到,随着粗糙度的上升,镜面反射区域会增加,但是镜面反射的亮度却会下降。

  • 如果每个像素的镜面反射强度都一样(不管反射轮廓的大小),那么粗糙的平面就会放射出过多的能量,而这样就违背了能量守恒定律。

这也就是为什么正如我们看到的一样,光滑平面的镜面反射更强烈而粗糙平面的反射更昏暗。


为了遵守能量守恒定律,我们需要对漫反射光和镜面反射光做出明确的区分。

当一束光线碰撞到一个表面的时候,它就会分离成一个==折射部分==和一个==反射部分==。

  • 反射部分就是会直接反射开而不进入平面的那部分光线,也就是我们所说的镜面光照。
  • 而折射部分就是余下的会进入表面并被吸收的那部分光线,也就是我们所说的漫反射光照。

这里还有一些细节需要处理,因为当光线接触到一个表面的时候折射光是不会立即就被吸收的。通过物理学我们可以得知,光线实际上可以被认为是一束没有耗尽就不停向前运动的能量,而光束是通过碰撞的方式来消耗能量。

每一种材料都是由无数微小的粒子所组成,这些粒子都能如下图所示一样与光线发生碰撞。这些粒子在每次的碰撞中都可以吸收光线所携带的一部分或者是全部的能量而后转变成为热量。

image

一般来说,并非全部能量都会被吸收,而光线也会继续沿着(基本上)随机的方向==发散==,然后再和其他的粒子碰撞直至能量完全耗尽或者再次离开这个表面。而光线脱离物体表面后将会协同构成该表面的(漫反射)颜色。

不过在基于物理的渲染之中我们进行了==简化==,假设==对平面上的每一点所有的折射光都会被完全吸收==而不会散开。而有一些被称为次表面散射(Subsurface Scattering)技术的着色器技术将这个问题考虑了进去,它们显著地提升了一些诸如皮肤,大理石或者蜡质这样材质的视觉效果,不过伴随而来的代价是性能的下降。


对于==金属==(Metallic)表面,当讨论到反射与折射的时候还有一个细节需要注意。金属表面对光的反应与非金属(也被称为介电质(Dielectrics))表面相比是不同的。

  • 它们遵从的反射与折射原理是相同的,但是所有的折射光都会被直接吸收而不会散开,只留下反射光或者说镜面反射光。亦即是说,==金属表面只会显示镜面反射颜色==,而不会显示出漫反射颜色。

由于==金属==与==电介质==之间存在这样明显的==区别==,因此它们两者在PBR渲染管线中被区别处理,而我们将在文章的后面进一步详细探讨这个问题


反射光与折射光之间的这个区别使我们得到了另一条关于能量守恒的经验结论:==反射光与折射光它们二者之间是互斥的关系==。

无论何种光线,其被材质表面所反射的能量将无法再被材质吸收。因此,诸如折射光这样的余下的进入表面之中的能量正好就是我们计算完反射之后余下的能量。

我们按照能量守恒的关系,首先计算镜面反射部分,它的值等于入射光线被反射的能量所占的百分比。然后折射光部分就可以直接由镜面反射部分计算得出:

1
2
float kS = calculateSpecularComponent(...); // 反射/镜面 部分
float kD = 1.0 - ks; // 折射/漫反射 部分

这样我们就能在遵守能量守恒定律的前提下知道入射光线的反射部分与折射部分所占的总量了。按照这种方法折射/漫反射与反射/镜面反射所占的份额都不会超过1.0,如此就能保证它们的能量总和永远不会超过入射光线的能量。而这些都是我们在前面的光照教程中没有考虑的问题。

反射率方程

在这里我们引入了一种被称为渲染方程(Render Equation)的东西。它是某些聪明绝顶的人所构想出来的一个精妙的方程式,是如今我们所拥有的用来模拟光的视觉效果最好的模型。

基于物理的渲染所坚定遵循的是一种被称为==反射率方程==(The Reflectance Equation)的渲染方程的特化版本。要正确地理解PBR,很重要的一点就是要首先透彻地理解反射率方程:

image

反射率方程一开始可能会显得有些吓人,不过随着我们慢慢对其进行剖析,读者最终会逐渐理解它的。要正确地理解这个方程式,我们必须要稍微涉足一些辐射度量学(Radiometry)的内容。


辐射度量学是一种用来度量电磁场辐射(包括可见光)的手段。有很多种辐射度量(radiometric quantities)可以用来测量曲面或者某个方向上的光,但是我们将只会讨论其中和反射率方程有关的一种。

它被称为==辐射率==(Radiance),在这里用L来表示。辐射率被用来==量化来自单一方向上的光线的大小或者强度==。

由于辐射率是由许多物理变量集合而成的,一开始理解起来可能有些困难,因此我们首先关注一下这些物理量:

辐射通量

本质:用RGB值表示能量

辐射通量:辐射通量Φ表示的是一个光源所输出的能量,以瓦特为单位。

光是由==多种不同波长的能量所集合而成==的,而每种波长则与一种特定的(可见的)颜色相关。因此一个光源所放射出来的能量可以被视作这个光源包含的所有各种波长的一个函数。

波长介于390nm到700nm(纳米)的光被认为是处于可见光光谱中,也就是说它们是人眼可见的波长。在下面你可以看到一幅图片,里面展示了日光中不同波长的光所具有的能量:

image

辐射通量将会==计算这个由不同波长构成的函数的总面积==。

直接将这种对不同波长的计量作为参数输入计算机图形有一些不切实际,因此我们通常不直接使用波长的强度而是使用三原色编码,也就是RGB(或者按通常的称呼:光色)来作为辐射通量表示的简化。

这套编码确实会带来一些信息上的损失,但是这对于视觉效果上的影响基本可以忽略。

立体角

立体角:立体角用ω表示,它可以为我们描述投射到单位球体上的一个==截面的大小或者面积==。投射到这个单位球体上的==截面的面积==就被称为==立体角==(Solid Angle),你可以把立体角想象成为一个带有体积的方向:

image

可以把自己想象成为一个站在单位球面的中心的观察者,向着投影的方向看。这个投影轮廓的大小就是立体角。

辐射强度

辐射强度:辐射强度(Radiant Intensity)表示的是在==单位球面==上,==一个光源==向==每单位立体角==所投送的==辐射通量==。

举例来说,假设一个全向光源向所有方向均匀的辐射能量,辐射强度就能帮我们计算出它在一个单位面积(立体角)内的能量大小:

image

计算辐射强度的公式如下所示:

image

其中I表示辐射通量Φ除以立体角ω


在理解了辐射通量,辐射强度与立体角的概念之后,我们终于可以开始讨论辐射率 Radiance的方程式了。这个方程表示的是,一个拥有辐射强度Φ的光源在单位面积A,单位立体角ω上的辐射出的总能量:

image image

辐射率是辐射度量学上表示一个区域平面上光线总量的物理量,它受到入射(Incident)(或者来射)光线与平面法线间的夹角θ的余弦值cosθ的影响:当直接辐射到平面上的程度越低时,光线就越弱,而当光线完全垂直于平面时强度最高。

这和我们在前面的[基础光照](https://learnopengl-cn.github.io/02 Lighting/02 Basic Lighting/)教程中对于漫反射光照的概念相似,其中cosθ就直接对应于光线的方向向量和平面法向量的点积yu:

1
float cosTheta = dot(lightDir, N);

辐射率方程很有用,因为它把大部分我们感兴趣的物理量都包含了进去。如果我们把立体角ω和面积A看作是无穷小的,那么我们就能用==辐射率来表示单束光线穿过空间中的一个点的通量==。

这就使我们可以计算得出作用于单个(片段)点上的单束光线的辐射率,我们实际上把立体角ω转变为方向向量ω然后把面A转换为点p。这样我们就能直接在我们的着色器中使用辐射率来计算单束光线对每个片段的作用了。

可参考链接:路径追踪(Path Tracing)与渲染方程(Render Equation) https://zhuanlan.zhihu.com/p/370162390

  • 但是通常情况下,辐照度Irradiance的公式里不写cosθ,我们默认指的就是投影后的光线能量。

事实上,当涉及到辐射率时,我们通常关心的是所有投射到点pp上的光线的总和,而这个和就称为==辐射照度==或者==辐照度==(Irradiance)。在理解了辐射率和辐照度的概念之后,让我们再回过头来看看反射率方程:

image

fr是BRDF,分析可参见:https://www.bilibili.com/video/BV1X7411F744?t=2078.9&p=15

我们知道在渲染方程中L代表通过某个无限小的立体角ωi在某个点上的辐射率,而立体角可以视作是入射方向向量ωi。注意我们利用光线和平面间的入射角的余弦值cos⁡θ来计算能量,亦即从辐射率公式L转化至反射率公式时的n⋅ωi。

用ωo表示观察方向,也就是出射方向,反射率公式计算了点p在ωo方向上被反射出来的辐射率Lo(p,ωo)的总和。或者换句话说:Lo表示了从ωo方向上观察,光线投射到点p上反射出来的辐照度。


基于反射率公式是围绕所有入射辐射率的总和,也就是辐照度来计算的,所以我们需要计算的就不只是是单一的一个方向上的入射光,而是一个以点p为球心的半球领域Ω内所有方向上的入射光。一个==半球领域==(Hemisphere)可以描述为以平面法线n为轴所环绕的半个球体:

image

为了计算某些面积的值,或者像是在半球领域的问题中计算某一个体积的时候我们会需要用到一种称为积分(Integral)的数学手段,也就是反射率公式中的符号∫,它的运算包含了半球领域Ω内所有入射方向上的dωi。

积分运算的值等于一个函数曲线的面积,它的计算结果要么是解析解要么就是数值解。

由于渲染方程和反射率方程都没有解析解,我们将会用离散的方法来求得这个积分的数值解。

这个问题就转化为,在半球领域Ω中按一定的步长将反射率方程分散求解,然后再按照步长大小将所得到的结果平均化。这种方法被称为==黎曼和==(Riemann sum) ,我们可以用下面的代码粗略的演示一下:

1
2
3
4
5
6
7
8
9
10
11
int steps = 100;
float sum = 0.0f;
vec3 P = ...;
vec3 Wo = ...;
vec3 N = ...;
float dW = 1.0f / steps;
for(int i = 0; i < steps; ++i)
{
vec3 Wi = getNextIncomingLightDir(i);
sum += Fr(p, Wi, Wo) * L(p, Wi) * dot(N, Wi) * dW;
}

通过利用dW来对所有离散部分进行缩放,其和最后就等于积分函数的总面积或者总体积。这个用来对每个离散步长进行缩放的dW可以认为就是反射率方程中的dωi 。

在数学上,用来计算积分的dωi 表示的是一个连续的符号,而我们使用的dW在代码中和它并没有直接的联系(因为它代表的是==黎曼和中的离散步长==),这样说是为了可以帮助你理解。

请牢记,使用离散步长得到的是函数总面积的一个==近似值==。细心的读者可能已经注意到了,我们可以通过增加离散部分的数量来提高黎曼和的准确度(Accuracy)。


==反射率方程==概括了==在半球领域Ω内,碰撞到了点p上的所有入射方向ωi 上的光线的辐射率,并受到fr的约束,然后返回观察方向上反射光的Lo==。正如我们所熟悉的那样,入射光辐射率可以由[光源](https://learnopengl-cn.github.io/07 PBR/02 Lighting/)处获得,此外还可以利用一个环境贴图来测算所有入射方向上的辐射率,我们将在未来的[IBL](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/)教程中讨论这个方法。

现在唯一剩下的未知符号就是frf了,它被称为BRDF,或者==双向反射分布函数==(Bidirectional Reflective Distribution Function) ,==它的作用是基于表面材质属性来对入射辐射率进行缩放或者加权==。

BRDF

BRDF,或者说双向反射分布函数,它接受==入射(光)方向ωi==,==出射(观察)方向ωo==,==平面法线n==以及一个用来==表示微平面粗糙程度的参数a==作为函数的==输入参数==。

BRDF可以近似的求出每束光线对一个给定了材质属性的平面上最终反射出来的光线所作出的贡献程度。

  • 举例来说,如果一个平面拥有完全光滑的表面(比如镜面),那么对于所有的入射光线ωi(除了一束以外)而言BRDF函数都会返回0.0 ,只有一束与出射光线ωo拥有==相同(被反射)角度==的光线会得到1.0这个返回值。

BRDF基于我们之前所探讨过的微平面理论来近似的求得材质的反射与折射属性。

对于一个BRDF,为了实现物理学上的可信度,它必须遵守==能量守恒定律==,也就是说反射光线的总和永远不能超过入射光线的总量。

严格上来说,同样采用ωi和ωo作为输入参数的 Blinn-Phong光照模型也被认为是一个BRDF。然而由于Blinn-Phong模型并没有遵循能量守恒定律,因此它不被认为是基于物理的渲染。

现在已经有很好几种BRDF都能近似的得出物体表面对于光的反应,但是几乎所有实时渲染管线使用的都是一种被称为==Cook-Torrance BRDF模型==。


Cook-Torrance BRDF兼有漫反射和镜面反射两个部分:

image

这里的kd是早先提到过的入射光线中被折射部分的能量所占的比率,而ks被反射部分的比率。


BRDF的左侧表示的是漫反射部分,这里用flambert来表示。它被称为==Lambertian==漫反射,这和我们之前在漫反射着色中使用的常数因子类似,用如下的公式来表示:

image

c表示==表面颜色==(回想一下漫反射表面纹理)。除以π是为了对漫反射光进行标准化,因为前面含有BRDF的积分方程是受π影响的(我们会在[IBL](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/)的教程中探讨这个问题的)。

你也许会感到好奇,这个Lambertian漫反射和我们之前经常使用的漫反射到底有什么关系:之前我们是用表面法向量与光照方向向量进行点乘,然后再将结果与平面颜色相乘得到漫反射参数。点乘依然还在,但是却不在BRDF之内,而是转变成为了Lo积分末公式末尾处的n⋅ωi 。

目前存在着许多不同类型的模型来实现BRDF的漫反射部分,大多看上去都相当真实,但是相应的运算开销也非常的昂贵。不过按照Epic公司给出的结论,Lambertian漫反射模型已经足够应付大多数实时渲染的用途了。

对金属来说,折射光全部吸收,故没有漫反射。取而代之的是反射,主要体现在反射部分的Fresnel项中。和非金属不同,Fresnel的反射项需带有颜色信息。


BRDF的镜面反射部分要稍微更高级一些,它的形式如下所示:

image

Cook-Torrance BRDF的镜面反射部分包含==三个函数==,此外分母部分还有==一个标准化因子== 。字母D,F与G分别代表着一种类型的函数,各个函数分别用来近似的计算出表面反射特性的一个特定部分。

三个函数分别为==法线分布函数==(Normal Distribution Function),==菲涅尔方程==(Fresnel Rquation)和==几何函数==(Geometry Function):

  • 法线分布函数:估算在受到表面粗糙度的影响下,朝向方向与半程向量一致的微平面的数量。这是用来估算微平面的主要函数。
  • 几何函数:描述了微平面自成阴影的属性。当一个平面相对比较粗糙的时候,平面表面上的微平面有可能挡住其他的微平面从而减少表面所反射的光线。
  • 菲涅尔方程:菲涅尔方程描述的是在不同的观察视角下表面所反射的光线所占的比率。

以上的每一种函数都是用来==估算相应的物理参数==的,而且你会发现用来实现相应物理机制的每种函数都有==不止一种形式==。它们有的非常真实,有的则性能高效。你可以按照自己的需求任意选择自己想要的函数的实现方法。

英佩游戏公司的Brian Karis对于这些函数的多种近似实现方式进行了大量的研究

我们将会采用Epic Games在Unreal Engine 4中所使用的函数,其中D使用Trowbridge-Reitz GGX,F使用Fresnel-Schlick近似(Fresnel-Schlick Approximation),而G使用Smith’s Schlick-GGX。

法线分布函数

==法线分布函数D==,从统计学上近似地表示了与某些(半程)向量h取向一致的微平面的比率。

  • 举例来说,假设给定向量hh,如果我们的微平面中有35%与向量hh取向一致,则法线分布函数或者说NDF将会返回0.35。

目前有很多种NDF都可以从统计学上来估算微平面的总体取向度,只要给定一些粗糙度的参数。我们马上将要用到的是Trowbridge-Reitz GGX:

image

在这里h表示用来与平面上微平面做比较用的半程向量,而a表示表面粗糙度。

如果我们把h当成是==不同粗糙度==参数下,表面法向量和光线方向向量之间的中间向量的话,我们可以得到如下图示的效果(h的表述是不是有点奇怪):

image

当粗糙度很低(也就是说表面很光滑)的时候,与半程向量取向一致的微平面会高度集中在一个很小的半径范围内。由于这种集中性,NDF最终会生成一个非常明亮的斑点。

但是当表面比较粗糙的时候,微平面的取向方向会更加的随机。你将会发现与hh向量取向一致的微平面分布在一个大得多的半径范围内,但是同时较低的集中性也会让我们的最终效果显得更加灰暗。

使用GLSL代码编写的Trowbridge-Reitz GGX法线分布函数是下面这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
float D_GGX_TR(vec3 N, vec3 H, float a)
{
float a2 = a*a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH*NdotH;

float nom = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;

return nom / denom;
}

可参考链接:

image

几何函数

几何函数从统计学上近似的求得了微平面间==相互遮蔽的比率==,这种相互遮蔽会损耗光线的能量。

image

与NDF类似,几何函数采用一个材料的==粗糙度参数作为输入参数==,粗糙度较高的表面其微平面间相互遮蔽的概率就越高。

我们将要使用的几何函数是GGX与Schlick-Beckmann近似的结合体,因此又称为Schlick-GGX:

image

这里的k是α的重映射(Remapping),取决于我们要用的是针对直接光照还是针对IBL光照的几何函数:

image

注意,根据你的引擎把粗糙度转化为α的方式不同,得到α的值也有可能不同。在接下来的教程中,我们将会广泛的讨论这个重映射是如何起作用的。

为了有效的估算几何部分,需要将==观察方向==(==几何遮蔽==(Geometry Obstruction))和==光线方向向量==(==几何阴影==(Geometry Shadowing))都考虑进去。

我们可以使用史密斯法(Smith’s method)来把两者都纳入其中:

image

使用史密斯法与Schlick-GGX作为GsubGsub可以得到如下所示==不同粗糙度==的视觉效果:

image

几何函数是一个值域为[0.0, 1.0]的乘数,其中白色或者说1.0表示没有微平面阴影,而黑色或者说0.0则表示微平面彻底被遮蔽。

使用GLSL编写的几何函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float GeometrySchlickGGX(float NdotV, float k)
{
float nom = NdotV;
float denom = NdotV * (1.0 - k) + k;

return nom / denom;
}

float GeometrySmith(vec3 N, vec3 V, vec3 L, float k)
{
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx1 = GeometrySchlickGGX(NdotV, k);
float ggx2 = GeometrySchlickGGX(NdotL, k);

return ggx1 * ggx2;
}

菲涅尔方程

菲涅尔(发音为Freh-nel)方程描述的是被反射的光线对比光线被折射的部分所占的比率,这个比率会随着我们观察的角度不同而不同。(不太严谨,分母是反射+折射哦)

当光线碰撞到一个表面的时候,菲涅尔方程会根据观察角度告诉我们被反射的光线所占的百分比。利用这个反射比率和能量守恒原则,我们可以直接得出光线被折射的部分以及光线剩余的能量。

当垂直观察的时候,任何物体或者材质表面都有一个基础反射率(Base Reflectivity),但是如果以一定的角度往平面上看的时候所有反光都会变得明显起来。

  • 你可以自己尝试一下,用垂直的视角观察你自己的木制/金属桌面,此时一定只有最基本的反射性。
  • 但是如果你从近乎90度(译注:应该是指和法线的夹角)的角度观察的话反光就会变得明显的多。如果从理想的90度视角观察,所有的平面理论上来说都能完全的反射光线。

这种现象因菲涅尔而闻名,并体现在了菲涅尔方程之中。

菲涅尔方程是一个相当复杂的方程式,不过幸运的是菲涅尔方程可以用Fresnel-Schlick近似法求得近似解:

image

F0表示平面的基础反射率,它是利用所谓折射指数(Indices of Refraction)或者说IOR计算得出的。

F0的含义可以参考Youtube-BRDF的[Fresnel reflectance](#Fresnel reflectance)

  • F0通常指的是入射角为0度时的Fresnel反射率。

然后正如你可以从球体表面看到的那样,我们越是朝球面掠角的方向上看(此时视线和表面法线的夹角接近90度)菲涅尔现象就越明显,反光就越强:

image

菲涅尔方程还存在一些细微的问题。其中一个问题是Fresnel-Schlick近似仅仅对==电介质==或者说==非金属表面有定义==。

对于导体(Conductor)表面(金属),使用它们的折射指数计算基础折射率并不能得出正确的结果,这样我们就需要使用一种不同的菲涅尔方程来对导体表面进行计算。

由于这样很不方便,所以我们预计算出平面对于==法向入射==的结果(F0,处于0度角,好像直接看向表面一样),然后基于相应观察角的Fresnel-Schlick近似对这个值进行==插值==,用这种方法来进行进一步的估算。这样我们就能对金属和非金属材质使用同一个公式了。

平面对于法向入射的响应或者说基础反射率可以在一些大型数据库中找到,比如这个。下面列举的这一些常见数值就是从Naty Hoffman的课程讲义中所得到的:

image

这里可以观察到的一个有趣的现象,所有电介质材质表面的基础反射率都不会高于0.17,这其实是例外而非普遍情况。导体材质表面的基础反射率起点更高一些并且(大多)在0.5和1.0之间变化。

此外,对于==导体或者金属表面而言基础反射率一般是带有色彩==的,这也是为什么F0要用RGB三原色来表示的原因(法向入射的反射率可随波长不同而不同)。这种现象我们**==只能==**在金属表面观察的到。


这些金属表面相比于电介质表面所独有的特性引出了所谓的==金属工作流==的概念。也就是我们需要额外使用一个被称为金属度(Metalness)的参数来参与编写表面材质。金属度用来描述一个==材质表面是金属还是非金属的==。

理论上来说,一个表面的金属度应该是二元的:要么是金属要么不是金属,不能两者皆是。但是,大多数的渲染管线都允

许在0.0至1.0之间线性的调配金属度。这主要是由于材质纹理精度不足以描述一个拥有诸如细沙/沙状粒子/刮痕的金属表面。通过对这些小的类非金属粒子/刮痕调整金属度值,我们可以获得非常好看的视觉效果。

通过预先计算电介质与导体的F0值,我们可以对两种类型的表面使用相同的Fresnel-Schlick近似,但是如果是==金属表面的话就需要对基础反射率添加色彩==。我们一般是按下面这样来实现的:

1
2
vec3 F0 = vec3(0.04);
F0 = mix(F0, surfaceColor.rgb, metalness);

F0取最常见的电介质表面的平均值,这又是一个近似值。

不过对于大多数电介质表面而言使用0.04作为基础反射率已经足够好了,而且可以在不需要输入额外表面参数的情况下得到物理可信的结果。

然后,基于金属表面特性,我们要么使用电介质的基础反射率要么就使用F0来作为表面颜色。因为金属表面会==吸收所有折射光线而没有漫反射==,所以我们可以==直接使用表面颜色纹理==来作为它们的==基础反射率==。

Fresnel Schlick近似可以用代码表示为:

1
2
3
4
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

其中cosTheta通常是表面法向量n与观察方向v的点乘的结果。(但是在文档后续的实现部分使用的是HdotV

image

个人感觉:点积形式还是根据实际情况选择,对于直接光源,光源位置肯定是已知的,那么就可以求出半程向量用HdotV计算Fresnel。但如果是间接光,光源是来自周围,此时就使用NdotV计算(NdotV形式在后续间接光漫反射部分会提及)。

Cook-Torrance反射率方程

随着Cook-Torrance BRDF中所有元素都介绍完毕,我们现在可以将基于物理的BRDF纳入到最终的反射率方程当中去了:

image

这个方程现在完整的描述了一个基于物理的渲染模型,它现在可以认为就是我们一般意义上理解的基于物理的渲染也就是PBR。

如果你还没有能完全理解我们将如何把所有这些数学运算结合到一起并融入到代码当中去的话也不必担心。在下一个教程当中,我们将探索如何实现反射率方程来在我们渲染的光照当中获得更加物理可信的结果,而所有这些零零星星的碎片将会慢慢组合到一起来。

编写PBR材质

在了解了PBR后面的数学模型之后,最后我们将通过说明==美术师==一般是如何编写一个我们可以直接输入PBR的平面物理属性的来结束这部分的讨论。

PBR渲染管线所需要的每一个表面参数都可以用纹理来定义或者建模。使用纹理可以让我们逐个片段的来控制每个表面上特定的点对于光线是如何响应的:不论那个点是不是金属,粗糙或者平滑,也不论表面对于不同波长的光会有如何的反应。

在下面你可以看到在一个PBR渲染管线当中经常会碰到的纹理列表,还有将它们输入PBR渲染器所能得到的相应的视觉输出:

image

反照率:反照率(Albedo)纹理为每一个金属的纹素(Texel)(纹理像素)指定表面颜色或者基础反射率。

  • 这和我们之前使用过的漫反射纹理相当类似,不同的是所有光照信息都是由一个纹理中提取的。漫反射纹理的图像当中常常包含一些细小的阴影或者深色的裂纹,而反照率纹理中是不会有这些东西的。它应该只包含表面的颜色(或者折射吸收系数)。

法线:法线贴图纹理和我们之前在[法线贴图](https://learnopengl-cn.github.io/05 Advanced Lighting/04 Normal Mapping/)教程中所使用的贴图是完全一样的。法线贴图使我们可以逐片段的指定独特的法线,来为表面制造出起伏不平的假象。

金属度:金属(Metallic)贴图逐个纹素的指定该纹素是不是金属质地的。根据PBR引擎设置的不同,美术师们既可以将金属度编写为灰度值又可以编写为1或0这样的二元值。

粗糙度:粗糙度(Roughness)贴图可以以纹素为单位指定某个表面有多粗糙。采样得来的粗糙度数值会影响一个表面的微平面统计学上的取向度。一个比较粗糙的表面会得到更宽阔更模糊的镜面反射(高光),而一个比较光滑的表面则会得到集中而清晰的镜面反射。

  • 某些PBR引擎预设采用的是对某些美术师来说更加直观的==光滑度==(Smoothness)贴图而非粗糙度贴图,不过这些数值在采样之时就马上用(1.0 – 光滑度)转换成了粗糙度。

AO:环境光遮蔽(Ambient Occlusion)贴图或者说AO贴图为表面和周围潜在的几何图形指定了一个额外的阴影因子。

  • 比如如果我们有一个砖块表面,反照率纹理上的砖块裂缝部分应该没有任何阴影信息。然而AO贴图则会把那些光线较难逃逸出来的暗色边缘指定出来。在光照的结尾阶段引入环境遮蔽可以明显的提升你场景的视觉效果。
  • 网格/表面的环境遮蔽贴图要么通过手动生成,要么由3D建模软件自动生成。

美术师们可以在纹素级别设置或调整这些基于物理的输入值,还可以以现实世界材料的表面物理性质来建立他们的材质数据。

这是PBR渲染管线最大的优势之一,因为不论环境或者光照的设置如何改变,这些表面的性质是不会改变的,这使得美术师们可以更便捷地获取物理可信的结果。

在PBR渲染管线中编写的表面可以非常方便的在不同的PBR渲染引擎间共享使用,不论处于何种环境中它们看上去都会是正确的,因此看上去也会更自然。

延伸阅读

补充:Youtube-BRDF

Microfacet BRDF: Theory and Implementation of Basic PBR Materials:https://www.youtube.com/watch?v=gya7x9H3mV0

其他优质链接

image image

Fresnel reflectance

宏观Fresnel

image image image image

perpendicular:垂直的

举例:当光线垂直从空气入射到玻璃内时,有4%的光线被反射,96%被折射

不同波长Fresnel反射的比例不同

image

微观Fresnel

主要在θ1的确定

image image

Normal distribution function

image

观察可以发现,法线分布函数取决于==法线n==和==半程向量h==

image image

Geometry term

image image image

Cook-Torrance Microfacet BRDF

image

6.2 光照

引入

在[上一个教程](https://learnopengl-cn.github.io/07 PBR/01 Theory/)中,我们讨论了一些PBR的基础知识。在本章节中,我们把重点放在将之前讨论的理论转化为实际的渲染器,这个渲染器将使用直接的(或解析的)光源:比如点光源,定向灯或聚光灯。

我们先来看看上一个章提到的反射方程的最终版:

image

我们大致上清楚这个反射方程在干什么,但我们仍然留有一些迷雾尚未揭开。比如说我们究竟将怎样表示场景上的辐照度(Irradiance), 辐射率(Radiance) L?

我们知道辐射率L(在计算机图形领域中)衡量光源的辐射通量(Radiant flux)ϕ,或光源在给定立体角ω下发出的光能。在该情况下,不妨假设立体角ω无限小,这样辐射度(Radiance)就表示==光源在一条光线或单个方向向量上的辐射通量==。


基于以上的知识,我们如何将其转化为之前的教程中所积累的一些光照知识呢? 那么想象一下,我们有一个点光源(一个在所有方向都具有相同亮度的光源),它的辐射通量为用RGB表示为(23.47, 21.31, 20.79)。该光源的辐射强度(Radiant Intensity)等于其在==所有出射光线的辐射通量==。

然而,当我们为一个表面上的特定的点p着色时,在其半球领域Ω的所有可能的入射方向上,只有一个入射方向向量ωi直接来自于该点光源。

假设我们在场景中只有一个光源,位于空间中的某一个点,因而对于p点的其他可能的入射光线方向上的辐射率为0:

image

如果从一开始,我们就假设点光源==不受光线衰减==(光照强度会随着距离变暗)的影响,那么无论我们把光源放在哪,入射光线的辐射率总是一样的(除去入射角cosθ对辐射率的影响之外)。

这是因为无论我们从哪个角度观察它,点光源总具有相同的辐射强度,我们可以有效地将其辐射强度建模为其辐射通量: 一个常量向量(23.47, 21.31, 20.79)

然而,辐射率也需要==将位置p作为输入==,正如所有现实的点光源都会==受光线衰减影响==一样,点光源的辐射强度应该根据点p所在的位置和光源的位置以及他们之间的距离而做一些==缩放==。因此,根据原始的辐射方程,我们会根据表面法向量n和入射角度wi来缩放光源的辐射强度。


在==实现==上来说:对于==直接点光源==的情况,辐射率函数L先获取光源的颜色值, 然后光源和某点p的距离衰减,接着按照n⋅wi缩放,但是仅仅有一条入射角为wi的光线打在点p上, 这个wi同时也等于在p点光源的方向向量。写成代码的话会是这样:

1
2
3
4
5
vec3  lightColor  = vec3(23.47, 21.31, 20.79);
vec3 wi = normalize(lightPos - fragPos);
float cosTheta = max(dot(N, Wi), 0.0);
float attenuation = calculateAttenuation(fragPos, lightPos);
float radiance = lightColor * attenuation * cosTheta;

除了一些术语上的差异外,这段代码对你们来说应该很熟悉:这正是我们一直以来怎么==计算漫反射光照的==!当涉及到==直接光照==(direct lighting)时,辐射率的计算方式和我们之前计算只有一个光源照射在物体表面的时候非常相似。

请注意,这个假设成立的条件是==点光源体积无限小==,相当于在空间中的一个点。如果我们认为该光源是具有体积的,它的辐射率会在不只一个入射光方向上非零。

对于其它类型的从单点发出来的光源我们类似地计算出辐射率。

  • 比如,定向光(directional light)拥有恒定的wiwi而不会有衰减因子;
  • 而一个聚光灯光源则没有恒定的辐射强度,其辐射强度是根据聚光灯的方向向量来缩放的。

这也让我们回到了对于表面的半球领域(hemisphere)Ω的积分∫上。由于我们事先知道的所有贡献光源的位置,因此对物体表面上的一个点着色并==不需要我们尝试去求解积分==。我们可以直接拿光源的(已知的)数目,去计算它们的总辐照度,因为每个光源仅仅只有一个方向上的光线会影响物体表面的辐射率。

这使得PBR==对直接光源的计算相对简单==,因为我们只需要有效地遍历所有有贡献的光源。而当我们之后把环境照明也考虑在内的[IBL](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/)教程中,我们就必须采取积分去计算了,这是因为光线可能会在任何一个方向入射。

一个PBR表面模型

现在让我们开始写片段着色器来实现上述的PBR模型吧~ 首先我们需要把PBR相关的输入放进==片段着色器==。

1
2
3
4
5
6
7
8
9
10
11
12
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

uniform vec3 camPos;

uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

我们把通用的顶点着色器的输出作为输入的一部分。另一部分输入则是物体表面模型的一些材质参数。

然后在片段着色器的开始部分我们做一下任何光照算法都需要做的计算:

1
2
3
4
5
6
void main()
{
vec3 N = normalize(Normal);
vec3 V = normalize(camPos - WorldPos);
[...]
}

直接光照

在本教程的例子中我们会采用总共4个点光源来直接表示场景的辐照度(Irrradiance)。

为了满足反射率方程,我们循环遍历每一个光源,计算他们各自的辐射率然后求和,接着根据BRDF和光源的入射角来缩放该辐射率。

我们可以把循环当作在物体的半球领域内对所有直接光源求积分。首先我们来计算一些可以预计算的光照变量:

1
2
3
4
5
6
7
8
9
10
vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i)
{
vec3 L = normalize(lightPositions[i] - WorldPos);
vec3 H = normalize(V + L);

float distance = length(lightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = lightColors[i] * attenuation;
[...]

由于我们在线性空间内计算光照(我们会在着色器的最后进行Gamma校正),我们使用在物理上更为准确的平方倒数作为衰减。

相对于物理上正确来说,你可能仍然想使用常量,线性和二次衰减方程(他们在物理上相对不准确),却可以为您提供在光的能量衰减更多的控制。


然后,对于每一个光源我们都想计算完整的 Cook-Torrance specular BRDF项:

image

首先我们想计算的是镜面反射和漫反射之间的比值,或者说与表面折射的光线相比,它反射了多少光线。 我们从[上一个教程](https://learnopengl-cn.github.io/07 PBR/01 Theory/)知道可以使用菲涅尔方程计算(注意这里用的clamp是为了避免黑点):

1
2
3
4
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

Fresnel-Schlick近似法接收一个参数F0,被称为0°入射角的反射率,或者说是直接(垂直)观察表面时有多少光线会被反射。

  • 这个参数F0会因为==材料不同而不同==,而且==对于金属材质会带有颜色==。

在PBR金属流中我们简单地认为大多数的绝缘体在F0为0.04的时候看起来视觉上是正确的,对于金属表面我们根据反射率特别地指定F0。 因此代码上看起来会像是这样:

1
2
3
vec3 F0 = vec3(0.04); 
F0 = mix(F0, albedo, metallic);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

dot(H, V)在一些情况下也可以用于计算Fresnel效应,但在Fresnel-Schlick方程中,更常使用的是法线和视角向量的夹角。

可以看到,对于非金属表面F0始终为0.04。对于金属表面,我们根据初始的F0和表现金属属性的反射率进行线性插值。

我们已经算出F, 剩下的项就是计算法线分布函数D和几何遮蔽函数G了。


在直接PBR光照着色器中D和G的计算代码类似于:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
float DistributionGGX(vec3 N, vec3 H, float roughness)
{
float a = roughness*roughness;
float a2 = a*a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH*NdotH;

float num = a2;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;

return num / denom;
}

float GeometrySchlickGGX(float NdotV, float roughness)
{
float r = (roughness + 1.0);
float k = (r*r) / 8.0;

float num = NdotV;
float denom = NdotV * (1.0 - k) + k;

return num / denom;
}
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx2 = GeometrySchlickGGX(NdotV, roughness);
float ggx1 = GeometrySchlickGGX(NdotL, roughness);

return ggx1 * ggx2;
}

这里比较重要的是和[理论章节](https://learnopengl-cn.github.io/07 PBR/01 Theory/)相比,我们直接把粗糙度(roughness)作为参数传给了上述函数;通过这种方式,我们可以针对每一个不同的项对粗糙度做一些修改。

根据迪士尼公司给出的观察以及后来被Epic Games公司采用的光照模型,在几何遮蔽函数和法线分布函数中==采用粗糙度的平方==会让光照看起来更加自然。

现在两个函数都给出了定义,在计算反射的循环中计算NDF和G项就变得很简单:

1
2
float NDF = DistributionGGX(N, H, roughness);       
float G = GeometrySmith(N, V, L, roughness);

这样我们就凑够了足够的项来计算Cook-Torrance BRDF:

1
2
3
vec3 nominator    = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001;
vec3 specular = nominator / denominator;

nominator:分子

denominator:分母

注意我们在分母项中加了一个0.001为了避免出现除零错误。现在我们终于可以计算每个光源在反射率方程中的贡献值了!


因为菲涅尔方程直接给出了kS, 我们可以使用F表示所有打在物体表面上的镜面反射光的贡献。 从kSkS我们很容易计算折射的比值kD:

1
2
3
4
vec3 kS = F;
vec3 kD = vec3(1.0) - kS;

kD *= 1.0 - metallic; //KD只对非金属有效

我们可以看作kS表示光能中被反射的能量的比例, 而剩下的光能会被折射, 比值即为kD。

更进一步来说,因为==金属不会折射光线==,因此不会有漫反射。所以如果表面是金属的,我们会把系数kD变为0。

这样,我们终于集齐所有变量来计算我们出射光线的值:

1
2
3
4
5
    const float PI = 3.14159265359;

float NdotL = max(dot(N, L), 0.0);
Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}

最终的结果Lo,或者说是出射光线的辐射率,实际上是反射率方程的在半球领域Ω的积分的结果。

但是我们实际上不需要去求积分,因为对于所有可能的入射光线方向我们知道只有4个方向的入射光线会影响片段的着色。因为这样,我们可以直接循环N次计算这些入射光线的方向(N也就是场景中光源的数目)。

剩下的工作就是加一个环境光照项给Lo,然后我们就拥有了片段的最后颜色:

1
2
vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color = ambient + Lo;

线性空间和HDR渲染

直到现在,我们假设的所有计算都在==线性的颜色空间==中进行的,因此我们需要在着色器最后做[伽马矫正](https://learnopengl-cn.github.io/07 PBR/02 Lighting/)。 在线性空间中计算光照是非常重要的,因为PBR要求所有输入都是线性的,如果不是这样,我们就会得到不正常的光照。

另外,我们希望所有光照的输入都尽可能的接近他们在物理上的取值,这样他们的反射率或者说颜色值就会在色谱上有比较大的变化空间。

Lo作为结果可能会变大得很快(超过1),但是因为默认的低动态范围(LDR)而取值被截断。所以在伽马矫正之前我们采用色调映射使Lo从LDR的值映射为HDR的值。

1
2
color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));

这里我们采用的色调映射方法为Reinhard 操作,使得我们在伽马矫正后可以保留尽可能多的辐照度变化。 我们没有使用一个独立的帧缓冲或者采用后期处理,所以我们需要直接在每一步光照计算后采用==色调映射==和==伽马矫正==。

image

采用线性颜色空间和HDR在PBR渲染管线中非常重要。如果没有这些操作,几乎是不可能正确地捕获到因光照强度变化的细节,这最终会导致你的计算变得不正确,在视觉上看上去非常不自然。

完整的直接光照PBR着色器

现在剩下的事情就是把做好色调映射和伽马矫正的颜色值传给片段着色器的输出,然后我们就拥有了自己的直接光照PBR着色器。 为了完整性,这里给出了完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

// material parameters
uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

// lights
uniform vec3 lightPositions[4];
uniform vec3 lightColors[4];

uniform vec3 camPos;

const float PI = 3.14159265359;

float DistributionGGX(vec3 N, vec3 H, float roughness);
float GeometrySchlickGGX(float NdotV, float roughness);
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness);
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness);

void main()
{
vec3 N = normalize(Normal);
vec3 V = normalize(camPos - WorldPos);

vec3 F0 = vec3(0.04);
F0 = mix(F0, albedo, metallic);

// reflectance equation
vec3 Lo = vec3(0.0);
for(int i = 0; i < 4; ++i)
{
// calculate per-light radiance
vec3 L = normalize(lightPositions[i] - WorldPos);
vec3 H = normalize(V + L);
float distance = length(lightPositions[i] - WorldPos);
float attenuation = 1.0 / (distance * distance);
vec3 radiance = lightColors[i] * attenuation;

// cook-torrance brdf
float NDF = DistributionGGX(N, H, roughness);
float G = GeometrySmith(N, V, L, roughness);
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);

vec3 kS = F;
vec3 kD = vec3(1.0) - kS;
kD *= 1.0 - metallic;

vec3 nominator = NDF * G * F;
float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.001;
vec3 specular = nominator / denominator;

// add to outgoing radiance Lo
float NdotL = max(dot(N, L), 0.0);
Lo += (kD * albedo / PI + specular) * radiance * NdotL;
}

vec3 ambient = vec3(0.03) * albedo * ao;
vec3 color = ambient + Lo;

color = color / (color + vec3(1.0));
color = pow(color, vec3(1.0/2.2));

FragColor = vec4(color, 1.0);
}

希望经过上一个教程的[理论知识](https://learnopengl-cn.github.io/07 PBR/01 Theory/)以及学习过关于渲染方程的一些知识后,这个着色器看起来不会太可怕。

如果我们采用这个着色器,加上4个点光源和一些球体,同时令这些球体的金属性(metallic)和粗糙度(roughness)沿垂直和水平方向分别变化,我们会得到这样的结果:

image

(上述图片)从下往上球体的金属性从0.0变到1.0, 从左到右球体的粗糙度从0.0变到1.0。你可以看到仅仅改变这两个值,显示的效果会发生巨大的改变!

带贴图的PBR

把我们系统扩展成可以接受纹理作为参数可以让我们对物体的材质有更多的自定义空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[...]
uniform sampler2D albedoMap;
uniform sampler2D normalMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;

void main()
{
vec3 albedo = pow(texture(albedoMap, TexCoords).rgb, 2.2);
vec3 normal = getNormalFromNormalMap();
float metallic = texture(metallicMap, TexCoords).r;
float roughness = texture(roughnessMap, TexCoords).r;
float ao = texture(aoMap, TexCoords).r;
[...]
}

不过需要注意的是一般来说反射率(albedo)纹理在美术人员创建的时候就已经在sRGB空间了,因此我们需要在光照计算之前先把他们转换到线性空间。

一般来说,环境光遮蔽贴图(ambient occlusion maps)也需要我们转换到线性空间。不过金属性(Metallic)和粗糙度(Roughness)贴图大多数时间都会保证在线性空间中。

只是把之前的球体的材质性质换成纹理属性,就在视觉上有巨大的提升:

image

相比起在网上找到的其他PBR渲染结果来说,尽管在视觉上不算是非常震撼,因为我们还没考虑到[基于图片的光照(IBL)](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/),但我们现在也算是有了一个基于物理的渲染器了,虽然还没考虑IBL,但你会发现你的光照看起来更加真实了。

6.3 IBL

注意:

  • 以下IBL的计算,均把物体当作一个质点。不要认为是在物体的每个表面都进行单独的计算!

  • 使用顶点位置归一化当作法线的时候不要误会!因为最后会使用法线采样立方体贴图,因此这个顶点位置向量实际代表法线的一个方向。

6.3.1 漫反射辐照度

本节思路:计算间接光漫反射。

  1. 转换HDR贴图。先拿到一张HDR环境贴图,将其转为立方体贴图形式,此时就能得到场景Radiance图。(作为入射Radiance)
  2. 预计算物体表面的Irradiance,生成新立方体环境贴图。
    • 具体做法:从==物体表面的法线出发==,对步骤1的环境立方体贴图进行==半球采样==(此节采用均匀采样,或者说均匀卷积,详见立方体贴图的卷积)。
  3. 实际应用时,使用==表面法线N采样==步骤2生成的新立方体贴图,得到该片段的Irradiance。利用Fresnel(NdotV)计算出反射和折射占比,用 折射占比 * Irradiance * albedo * ao 得到间接光漫反射部分。

==基于图像的光照==(Image based lighting, IBL)是一类光照技术的集合。其光源不是如[前一节教程](https://learnopengl-cn.github.io/07 PBR/02 Lighting/)中描述的可分解的直接光源,而是将周围环境整体视为一个大光源。

IBL 通常使用(取自现实世界或从3D场景生成的)环境立方体贴图 (Cubemap) ,我们可以将立方体贴图的每个像素视为光源,在渲染方程中直接使用它。这种方式可以有效地捕捉环境的全局光照和氛围,使物体更好地融入其环境。

由于基于图像的光照算法会捕捉部分甚至全部的环境光照,通常认为它是一种更精确的环境光照输入格式,甚至也可以说是一种全局光照的粗略近似。基于此特性,IBL 对 PBR 很有意义,因为当我们将环境光纳入计算之后,物体在物理方面看起来会更加准确。


要开始将 IBL 引入我们的 PBR 系统,让我们再次快速看一下反射方程:

image

如前所述,我们的主要目标是计算半球 Ω 上所有入射光方向 wi 的积分。解决上一节教程中的积分非常简单,因为我们事先已经知道了对积分有贡献的、若干精确的光线方向 wi 。

然而这次,来自周围环境的每个方向 wi 的入射光都可能具有一些辐射度,使得解决积分变得不那么简单。这为解决积分提出了两个要求:

  • 给定任何方向向量 wi,我们需要一些方法来获取这个方向上场景的辐射度。
  • 解决积分需要快速且实时。

现在看,第一个要求相对容易些。我们已经有了一些思路:表示环境或场景辐照度的一种方式是(预处理过的)环境立方体贴图,给定这样的立方体贴图,我们可以将立方体贴图的每个纹素视为一个光源。

如此,给定方向向量 wi ,获取此方向上场景辐射度的方法就简化为:

1
vec3 radiance =  texture(_cubemapEnvironment, w_i).rgb;

为了以更有效的方式解决积分,我们需要对其大部分结果进行预处理——或称预计算。为此,我们必须深入研究反射方程:

image

仔细研究反射方程,我们发现 BRDF 的漫反射 kd 和镜面 ks 项是相互独立的,我们可以将积分分成两部分:

image

通过将积分分成两部分,我们可以分开研究漫反射和镜面反射部分,==本教程的重点是漫反射积分部分==。

仔细观察漫反射积分,我们发现漫反射兰伯特项是一个常数项(颜色 c 、折射率 kd 和 π 在整个积分是常数),不依赖于任何积分变量。基于此,我们可以将常数项移出漫反射积分:

image

这给了我们一个只依赖于 wi 的积分(假设 p 位于环境贴图的中心)。

有了这些知识,我们就可以计算或==预计算一个新的立方体贴图==,它在==每个采样方向==——也就是纹素——中存==储漫反射积分的结果==,这些结果是通过卷积计算出来的。

==卷积的特性==是,对数据集中的一个条目做一些计算时,要考虑到数据集中的所有其他条目。这里的数据集就是场景的辐射度或环境贴图。因此,要对立方体贴图中的每个采样方向做计算,我们都会考虑半球 Ω 上的所有其他采样方向。

为了对环境贴图进行卷积,我们通过对半球 Ω 上的大量方向进行离散采样并对其辐射度取平均值,来计算每个输出采样方向 wo 的积分。用来采样方向 wi 的半球,要面向卷积的输出采样方向 wo 。

image

这个预计算的立方体贴图,在每个采样方向 wo 上存储其积分结果,可以理解为场景中所有能够击中面向 wo 的表面的间接漫反射光的预计算总和。

这样的立方体贴图被称为==辐照度图==,因为经过卷积计算的立方体贴图能让我们从任何方向有效地直接采样场景(预计算好的)辐照度。

辐射方程也依赖了位置 p ,不过这里我们假设它位于辐照度图的中心。这就意味着所有漫反射间接光只能来自同一个环境贴图,这样可能会破坏现实感(特别是在室内)。

渲染引擎通过在场景中放置多个反射探针来解决此问题,每个反射探针单独预计算其周围环境的辐照度图。这样,位置 p 处的辐照度(以及辐射度)是取离其最近的反射探针之间的辐照度(辐射度)内插值。

目前,我们假设==总是从中心采样环境贴图==,把反射探针的讨论留给后面的教程。

下面是一个环境立方体贴图及其生成的辐照度图的示例(由 Wave 引擎提供),每个方向 wo 的场景辐射度取平均值。

image

由于立方体贴图每个纹素中存储了( wo 方向的)卷积结果,辐照度图看起来有点像环境的平均颜色或光照图。使用任何一个向量对立方体贴图进行采样,就可以获取该方向上的场景辐照度。

PBR 和 HDR

我们在[光照教程](https://learnopengl-cn.github.io/07 PBR/02 Lighting/)中简单提到过:在 PBR 渲染管线中考虑==高动态范围==(High Dynamic Range, HDR)的场景光照非常重要。由于 PBR 的大部分输入基于实际物理属性和测量,因此为==入射光值==找到其物理等效值是很重要的。

无论我们是对光线的辐射通量进行研究性猜测,还是使用它们的直接物理等效值,诸如一个简单灯泡和太阳之间的这种差异都是很重要的,如果不在 [HDR](https://learnopengl-cn.github.io/05 Advanced Lighting/06 HDR/) 渲染环境中工作,就无法正确指定每个光的相对强度。

因此,PBR 和 HDR 需要密切合作,但这些与基于图像的光照有什么关系?我们在之前的教程中已经看到,让 PBR 在 HDR 下工作还比较容易。然而,回想一下基于图像的光照,我们将环境的间接光强度建立在环境立方体贴图的颜色值上,我们==需要某种方式将光照的高动态范围存储到环境贴图中==。

我们一直使用的环境贴图是以立方体贴图形式储存——如同一个[天空盒](https://learnopengl-cn.github.io/04 Advanced OpenGL/06 Cubemaps/)——属于低动态范围(Low Dynamic Range, LDR)。我们直接使用各个面的图像的颜色值,其范围介于 0.0 和 1.0 之间,计算过程也是照值处理。这样虽然可能适合视觉输出,但作为物理输入参数,没有什么用处。

辐射度的 HDR 文件格式

作用:提供环境入射Radiance

谈及辐射度的文件格式,辐射度文件的格式(扩展名为 .hdr)存储了一张完整的立方体贴图,所有六个面数据都是浮点数,==允许指定 0.0 到 1.0 范围之外的颜色值==,以使光线具有正确的颜色强度。

这个文件格式使用了一个聪明的技巧来存储每个浮点值:它并非直接存储每个通道的 32 位数据,而是==每个通道存储 8 位==,再以 ==alpha 通道存放指数==——虽然确实会导致精度损失,但是非常有效率,不过需要解析程序将每种颜色重新转换为它们的浮点数等效值。

sIBL 档案 中有很多可以免费获取的辐射度 HDR 环境贴图,下面是一个示例:

image

可能与您期望的完全不同,因为图像非常扭曲,并且没有我们之前看到的环境贴图的六个立方体贴图面。这张环境贴图是==从球体投影到平面上==,以使我们可以轻松地将环境信息存储到一张==等距柱状投影图==(Equirectangular Map) 中。

有一点确实需要说明:==水平视角附近分辨率较高,而底部和顶部方向分辨率较低==,在大多数情况下,这是一个不错的折衷方案,因为对于几乎所有渲染器来说,大部分有意义的光照和环境信息都在水平视角附近方向。

HDR 和 stb_image.h

直接加载辐射度 HDR 图像需要一些文件格式的知识,虽然不是很困难,但仍然很麻烦。幸运的是,一个常用的头文件库 stb_image.h 支持将辐射度 HDR 图像直接加载为一个浮点数数组,完全符合我们的需要。将 stb_image 添加到项目中之后,加载HDR图像非常简单,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "stb_image.h"
[...]

stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
float *data = stbi_loadf("newport_loft.hdr", &width, &height, &nrComponents, 0);
unsigned int hdrTexture;
if (data)
{
glGenTextures(1, &hdrTexture);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, width, height, 0, GL_RGB, GL_FLOAT, data);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

stbi_image_free(data);
}
else
{
std::cout << "Failed to load HDR image." << std::endl;
}

stb_image.h 自动将 HDR 值映射到一个浮点数列表:默认情况下,每个通道32位,每个颜色 3 个通道。我们要将等距柱状投影 HDR 环境贴图转存到 2D 浮点纹理中,这就是所要做的全部工作。(可代码中目标格式是16位RGB呀?)

从等距柱状投影到立方体贴图

当然也可以==直接使用等距柱状投影图获取环境信息==,但是这些操作还是显得==相对昂贵==,在这种情况下,直接==采样立方体贴图的性能更高==。因此,在本教程中,我们首先将等距柱状投影图==转换==为立方体贴图以备进一步处理。

请注意,在此过程中,我们还将展示如何对等距柱状格式的投影图采样,如同采样 3D 环境贴图一样,您可以自由选择您喜欢的任何解决方案。


要将等距柱状投影图转换为立方体贴图,我们需要渲染一个(单位)立方体,并从内部将等距柱状图投影到立方体的每个面,并将立方体的六个面的图像构造成立方体贴图。此立方体的顶点着色器只是按原样渲染立方体,并将其==局部坐标作为 3D 采样向量==传递给片段着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
#version 330 core
layout (location = 0) in vec3 aPos;

out vec3 localPos;

uniform mat4 projection;
uniform mat4 view;

void main()
{
localPos = aPos;
gl_Position = projection * view * vec4(localPos, 1.0);
}

这里传aPos没有其他含义,单纯表示法线方向(能360度无死角的表示)。因为之后会使用法线方向对生成的贴图进行采样。

而在片段着色器中,我们为立方体的每个部分着色,方法类似于将等距柱状投影图整齐地折叠到立方体的每个面一样。

为了实现这一点,我们==先获取片段的采样方向==,这个方向是从立方体的局部坐标进行插值得到的,然后使用此方向向量和一些==三角学魔法==对等距柱状投影图进行采样,如同立方体图本身一样。

我们直接将结果存储到立方体每个面的片段中,以下就是我们需要做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform sampler2D equirectangularMap;

const vec2 invAtan = vec2(0.1591, 0.3183);
vec2 SampleSphericalMap(vec3 v)
{
vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
uv *= invAtan;
uv += 0.5;
return uv;
}

void main()
{
vec2 uv = SampleSphericalMap(normalize(localPos)); // make sure to normalize localPos
vec3 color = texture(equirectangularMap, uv).rgb;

FragColor = vec4(color, 1.0);
}

如果给定HDR等距柱状投影图,在场景的中心渲染一个立方体,将得到如下所示的内容:

image

这表明我们有效地将等距柱状投影图映射到了立方体,但我们还需要将源HDR图像转换为立方体贴图纹理。为了实现这一点,我们必须对同一个立方体渲染六次,每次面对立方体的一个面,并用[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/)对象记录其结果:

1
2
3
4
5
6
7
8
unsigned int captureFBO, captureRBO;
glGenFramebuffers(1, &captureFBO);
glGenRenderbuffers(1, &captureRBO);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO);

当然,我们此时就可以生成相应的立方体贴图了,首先为其六个面预先分配内存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned int envCubemap;
glGenTextures(1, &envCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
for (unsigned int i = 0; i < 6; ++i)
{
// note that we store each face with 16 bit floating point values
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F,
512, 512, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

那剩下要做的就是将等距柱状 2D 纹理捕捉到立方体贴图的面上。

之前在[帧缓冲](https://learnopengl-cn.github.io/04 Advanced OpenGL/05 Framebuffers/)和[点阴影](https://learnopengl-cn.github.io/05 Advanced Lighting/03 Shadows/02 Point Shadows/)教程中讨论过的代码细节,我就不再次详细说明,实际过程可以概括为:面向立方体六个面设置六个不同的视图矩阵,给定投影矩阵的 fov 为 90 度以捕捉整个面,并渲染立方体六次,将结果存储在浮点帧缓冲中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
glm::mat4 captureProjection = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 10.0f);
glm::mat4 captureViews[] =
{
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f, 0.0f, 0.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 1.0f, 0.0f), glm::vec3(0.0f, 0.0f, 1.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, -1.0f, 0.0f), glm::vec3(0.0f, 0.0f, -1.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, 1.0f), glm::vec3(0.0f, -1.0f, 0.0f)),
glm::lookAt(glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, 0.0f, -1.0f), glm::vec3(0.0f, -1.0f, 0.0f))
};

// convert HDR equirectangular environment map to cubemap equivalent
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap", 0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);

glViewport(0, 0, 512, 512); // don't forget to configure the viewport to the capture dimensions.
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
equirectangularToCubemapShader.setMat4("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

renderCube(); // renders a 1x1 cube
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

我们采用帧缓冲的颜色值并围绕立方体贴图的每个面切换纹理目标,直接将场景渲染到立方体贴图的一个面上。一旦这个流程完毕——我们只需做一次——立方体贴图 envCubemap 就应该是原 HDR 图的环境立方体贴图版。

让我们编写一个非常简单的天空盒着色器来测试立方体贴图,用来显示周围的立方体贴图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 projection;
uniform mat4 view;

out vec3 localPos;

void main()
{
localPos = aPos;

mat4 rotView = mat4(mat3(view)); // remove translation from the view matrix
vec4 clipPos = projection * rotView * vec4(localPos, 1.0);

gl_Position = clipPos.xyww;
}

注意这里的小技巧 xyww 可以确保渲染的立方体片段的深度值总是 1.0,即最大深度,如[立方体贴图](https://learnopengl-cn.github.io/04 Advanced OpenGL/06 Cubemaps/)教程中所述。注意我们需要将深度比较函数更改为 GL_LEQUAL:

1
glDepthFunc(GL_LEQUAL);  

这个片段着色器直接使用立方体的片段局部坐标,对环境立方体贴图采样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#version 330 core
out vec4 FragColor;

in vec3 localPos;

uniform samplerCube environmentMap;

void main()
{
vec3 envColor = texture(environmentMap, localPos).rgb;

envColor = envColor / (envColor + vec3(1.0));
envColor = pow(envColor, vec3(1.0/2.2));

FragColor = vec4(envColor, 1.0);
}

我们使用插值的立方体顶点坐标对环境贴图进行采样,这些坐标直接对应于正确的采样方向向量。注意,相机的平移分量被忽略掉了,在立方体上渲染此着色器会得到非移动状态下的环境贴图。

另外还请注意,当我们将环境贴图的 HDR 值直接输出到默认的 LDR 帧缓冲时,希望对颜色值进行正确的色调映射。此外,默认情况下,几乎所有 HDR 图都处于线性颜色空间中,因此我们需要在写入默认帧缓冲之前应用[伽马校正](https://learnopengl-cn.github.io/05 Advanced Lighting/02 Gamma Correction/)。

现在,在之前渲染的球体上渲染环境贴图,效果应该如下图:

image

好的…我们用了相当多的设置终于来到了这里,我们设法成功地读取了 HDR 环境贴图,将它从等距柱状投影图转换为立方体贴图,并将 HDR 立方体贴图作为天空盒渲染到了场景中。

此外,我们设置了一个小系统来渲染立方体贴图的所有六个面,我们在计算环境贴图卷积时还会需要它。您可以在此处找到整个转化过程的源代码。

立方体贴图的卷积

作用:预计算表面的Irradiance,后续可用于该表面的Radiance计算

如本节教程开头所述,我们的主要目标是计算所有间接漫反射光的积分,其中==光照的辐照度(Irradiance)以环境立方体贴图的形式给出==。我们已经知道,在方向 wi 上采样 HDR 环境贴图,可以获得场景在此方向上的辐射度 L(p,wi) 。虽然如此,要解决积分,我们仍然不能仅从一个方向对环境贴图采样,而要从半球 Ω 上==所有可能的方向进行采样==,这对于片段着色器而言还是过于昂贵。

然而,计算上又不可能从 Ω 的每个可能的方向采样环境光照,理论上可能的方向数量是无限的。不过我们可以对有限数量的方向采样以近似求解,在半球内均匀间隔或随机取方向可以获得一个相当精确的辐照度近似值,从而离散地计算积分 ∫ 。

然而,对于每个片段实时执行此操作仍然太昂贵,因为仍然需要非常大的样本数量才能获得不错的结果,因此我们希望可以==预计算==。

既然半球的朝向决定了我们捕捉辐照度的位置,我们可以预先计算每个可能的半球朝向的辐照度,这些半球朝向涵盖了所有可能的出射方向 wo :

image

给定任何方向向量 wi ,我们可以对预计算的辐照度图采样以获取方向 wi 的总漫反射辐照度。

为了确定片段上间接漫反射光的数量(辐照度),我们获取==以表面法线为中心的半球的总辐照度==。获取场景辐照度的方法就简化为:

1
vec3 irradiance = texture(irradianceMap, N); //irradianceMap是之后要生成的

现在,为了生成辐照度贴图,我们需要将环境光照求卷积,转换为立方体贴图。

假设对于每个片段,表面的半球朝向法向量 N ,对立方体贴图进行卷积等于计算朝向 N 的半球 Ω 中每个方向 wi 的总平均辐射率。

image

想象p点是物体表面某一点,使用该点的法线N去采样预计算的Irradiance图,即可得到该点的Irradiance。该Irradiance可用于后续计算出Radiance。


值得庆幸的是,本节教程中所有繁琐的设置并非毫无用处,因为==我们现在可以直接获取转换后的立方体贴图,在片段着色器中对其进行卷积,渲染所有六个面,将其结果用帧缓冲捕捉到新的立方体贴图中==。

之前已经将等距柱状投影图转换为立方体贴图,这次我们可以采用完全相同的方法,但使用不同的片段着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform samplerCube environmentMap;

const float PI = 3.14159265359;

void main()
{
// the sample direction equals the hemisphere's orientation
vec3 normal = normalize(localPos);

vec3 irradiance = vec3(0.0);

[...] // convolution code

FragColor = vec4(irradiance, 1.0);
}

environmentMap 是从等距柱状投影图转换而来的 HDR 立方体贴图。 有很多方法可以对环境贴图进行卷积,但是对于本教程,我们的方法是:

  • 对于立方体贴图的每个纹素,在纹素所代表的方向的半球 Ω 内==生成固定数量的采样向量==,并对采样结果==取平均值==。数量固定的采样向量将均匀地分布在半球内部。

注意,积分是连续函数,在采样向量数量固定的情况下离散地采样只是一种==近似==计算方法,我们采样的向量越多,就越接近正确的结果。

反射方程的积分 ∫ 是围绕立体角 dw 旋转,而这个立体角相当难以处理。为了避免对难处理的立体角求积分,我们使用球坐标 θ 和 ϕ 来代替立体角。

image

对于围绕半球大圆的航向角 ϕ ,我们在 0 到 2π 内采样,而从半球顶点出发的倾斜角 θ ,采样范围是 0 到 1/2π 。于是我们更新一下反射积分方程:

image

求解积分需要我们在半球 Ω 内采集固定数量的离散样本并对其结果求平均值。分别给每个球坐标轴指定离散样本数量 n1 和 n2 以求其黎曼和,积分式会转换为以下离散版本:

image

公式推导有误:

image

2π / n1: ϕ方向上的平均

π / 2 * n2:θ方向上的平均

image

当我们离散地对两个球坐标轴进行采样时,每个采样近似代表了半球上的一小块区域,如上图所示。

注意,由于球的一般性质,当采样区域朝向中心顶部会聚时,天顶角 θ 变高,半球的离散采样区域变小。为了平衡较小的区域贡献度,我们使用 sinθ 来权衡区域贡献度,这就是多出来的 sin 的作用。(sin由立体角的推导得出吧?算了,问题不大)

给定每个片段的积分球坐标,对==半球进行离散采样==,过程代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
vec3 irradiance = vec3(0.0);  

//错误代码
//vec3 up = vec3(0.0, 1.0, 0.0);
//vec3 right = cross(up, normal);
//up = cross(normal, right);
//正确代码
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 right = normalize(cross(up, N));
up = normalize(cross(N, right));

float sampleDelta = 0.025;
float nrSamples = 0.0;
for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
{
// spherical to cartesian (in tangent space)
vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
// tangent space to world
vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N;

irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
nrSamples++;
}
}
irradiance = PI * irradiance * (1.0 / float(nrSamples));

我们以一个固定的 sampleDelta 增量值遍历半球,减小(或增加)这个增量将会增加(或减少)精确度。

在两层循环内,我们获取一个球面坐标并将它们转换为 3D 直角坐标向量,将向量从切线空间转换为世界空间,并使用此向量直接采样 HDR 环境贴图。我们将每个采样结果加到 irradiance,最后除以采样的总数,得到==平均采样辐照度==(Irradiance)。

请注意,我们将采样的颜色值乘以系数 cos(θ) ,因为较大角度的光较弱,而系数 sin(θ) 则用于权衡较高半球区域的较小采样区域的贡献度。


现在剩下要做的就是设置 OpenGL 渲染代码,以便我们可以对之前捕捉的 envCubemap 求卷积。首先我们创建一个辐照度立方体贴图(重复一遍,我们只需要在渲染循环之前执行一次):

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned int irradianceMap;
glGenTextures(1, &irradianceMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, irradianceMap);
for (unsigned int i = 0; i < 6; ++i)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 32, 32, 0,
GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

由于辐照度图对所有周围的辐射值取了平均值,因此它丢失了大部分高频细节,所以我们可以以较低的分辨率(32x32)存储,并让 OpenGL 的线性滤波完成大部分工作。接下来,我们将捕捉到的帧缓冲图像缩放到新的分辨率:

1
2
3
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32, 32);

我们使用卷积着色器——和捕捉环境立方体贴图类似的方式——来对环境贴图求卷积:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
irradianceShader.use();
irradianceShader.setInt("environmentMap", 0);
irradianceShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glViewport(0, 0, 32, 32); // don't forget to configure the viewport to the capture dimensions.
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
irradianceShader.setMat4("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, irradianceMap, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

renderCube();
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

现在,完成这个流程之后,我们应该得到了一个预计算好的辐照度图,可以直接将其用于IBL 计算。为了查看我们是否成功地对环境贴图进行了卷积,让我们将天空盒的环境采样贴图替换为辐照度贴图:

image

如果它看起来像模糊的环境贴图,说明您已经成功地对环境贴图进行了卷积。

PBR 和间接辐照度光照

辐照度图表示所有周围的间接光累积的反射率的漫反射部分的积分。注意光不是来自任何直接光源,而是来自周围环境,我们将间接漫反射和间接镜面反射视为环境光,取代了我们之前设定的常数项。

首先,务必将预计算的辐照度图(Irradiance)添加为一个立方体采样器:

1
uniform samplerCube irradianceMap;

给定一张辐照度图,它存储了场景中的所有间接漫反射光,获取片段的辐照度就简化为给定法线的一次纹理采样:

1
2
// vec3 ambient = vec3(0.03);
vec3 ambient = texture(irradianceMap, N).rgb;

然而,由于间接光照包括漫反射和镜面反射两部分,正如我们从分割版的反射方程中看到的那样,我们需要对漫反射部分进行相应的加权。与我们在前一节教程中所做的类似,我们使用菲涅耳公式来计算表面的间接反射率,我们从中得出折射率或称漫反射率:

1
2
3
4
5
vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
vec3 ambient = (kD * diffuse) * ao;

理论上计算光照时应该用Radiance,这里因为使用法线采样,说明Radiance是垂直表面的,因此数值上和Irradiance相同。

由于环境光来自半球内围绕法线 N 的所有方向,因此==没有一个确定的半向量来计算菲涅耳效应==。为了模拟菲涅耳效应,我们==用法线和视线之间的夹角==计算菲涅耳系数。

然而,之前我们是以受粗糙度影响的微表面半向量作为菲涅耳公式的输入,但我们目前没有考虑任何粗糙度,表面的反射率总是会相对较高。间接光和直射光遵循相同的属性,因此我们期望较粗糙的表面在边缘反射较弱。

由于我们没有考虑表面的粗糙度,间接菲涅耳反射在粗糙非金属表面上看起来有点过强(为了演示目的略微夸大):

image

我们可以通过在 Sébastien Lagarde 提出的 Fresnel-Schlick 方程中加入粗糙度项来缓解这个问题:

1
2
3
4
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(1.0 - cosTheta, 5.0);
}

在计算菲涅耳效应时纳入表面粗糙度,环境光代码最终确定为:

1
2
3
4
5
vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); 
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;
vec3 ambient = (kD * diffuse) * ao;

如您所见,实践上基于图像的光照计算非常简单,只需要采样一次立方体贴图,大部分的工作量在于将环境贴图预计算或卷积成辐照度图。


回到我们在[光照教程](https://learnopengl-cn.github.io/07 PBR/02 Lighting/)中建立的初始场景,场景中排列的球体金属度沿垂直方向递增,粗糙度沿水平方向递增。向场景中添加基于漫反射图像的光照之后,它看起来像这样:

image

现在看起来仍然有点奇怪,因为金属度较高的球体需要某种形式的反射以便看起来更像金属表面(因为金属表面没有漫反射),不过目前只有来自点光源的反射——而且可以说几乎没有。不过尽管如此,您也可以看出,球体在环境中的感觉更加和谐了(特别是在环境贴图之间切换的时候),因为表面会正确地响应环境光照。

进阶阅读

6.3.2 镜面反射IBL

本节思路:计算间接光镜面反射。

  1. 和间接光漫反射一样,先拿到一张场景HDR环境贴图,将其转为立方体贴图形式,这样就能方便得到场景Radiance图。

  2. 根据分割近似求和的推导,预计算Pre-filter Map。

    • 利用重要性采样,获取法线附近的微表面法线H。==假设==视角方向V和法线方向N一致,可得到反射向量L。利用L对步骤1的立方体环境贴图采样即可。(这里L本质就是法线附近的一些向量)
  3. 根据分割近似求和的推导,预计算BRDF map。

    • 利用重要性采样,获取法线附近的微表面法线H。假设观察方向向量的$\phi=0$,由法线N=(0,0,1)和输入的NdotV可得V。由V和H可得到反射向量L。代入公式(记结论)即可。

    • vec3 V;
      V.x = sqrt(1.0 - NdotV * NdotV);
      V.y = 0.0;
      V.z = NdotV;
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47

      4. 实际应用时,使用反射向量R采样Pre-Filter map(反射向量R由观察向量V和法线N计算得到),使用NdotV,粗糙度r采样预计算BRDF map。代入公式即可。

      ### 引入

      在[上一节教程](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/)中,我们预计算了辐照度图作为光照的间接漫反射部分,以将 PBR 与基于图像的照明相结合。在本教程中,我们将重点关注反射方程的镜面部分:

      <img src="Learn OpenGL.assets/image-20230830140958564.png" alt="image-20230830140958564" style="zoom:67%;" />

      你会注意到 Cook-Torrance 镜面部分(乘以ks)在整个积分上不是常数,不仅受入射光方向影响,**还**受视角影响。如果试图解算所有入射光方向加所有可能的视角方向的积分,二者组合数会极其庞大,实时计算太昂贵。

      Epic Games 提出了一个解决方案,他们==预计算镜面部分的卷积==,为实时计算作了一些妥协,这种方案被称为==分割求和近似法==(split sum approximation)。

      分割求和近似将方程的镜面部分==分割成两个独立的部分==,我们可以==单独求卷积==,然后==在 PBR 着色器中求和==,以用于间接镜面反射部分 IBL。

      分割求和近似法类似于我们之前求辐照图预卷积的方法,需要 HDR 环境贴图作为其卷积输入。

      为了理解,我们回顾一下反射方程,但这次只关注镜面反射部分(在[上一节教程](https://learnopengl-cn.github.io/07 PBR/03 IBL/02 Specular IBL/](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/))中已经剥离了漫反射部分):

      <img src="Learn OpenGL.assets/image-20230830141855966.png" alt="image-20230830141855966" style="zoom:50%;" />

      由于与辐照度卷积相同的(性能)原因,我们==无法以合理的性能实时求解==积分的镜面反射部分。因此,我们最好预计算这个积分,以得到像镜面 IBL 贴图这样的东西,==用**片段的法线**对这张图采样并计算==。

      但是,有一个地方有点棘手:我们能够预计算辐照度图,是因为其积分仅依赖于ωi,并且可以将漫反射反射率常数项移出积分,但这一次,积分不仅仅取决于ωi,从 BRDF 可以看出:

      <img src="Learn OpenGL.assets/image-20230830142054750.png" alt="image-20230830142054750" style="zoom:67%;" />

      这次==积分还依赖ωo==,我们无法用两个方向向量采样预计算的立方体图。如前一个教程中所述,位置p与此处无关。在实时状态下,对每种可能的ωi和ωo的组合预计算该积分是不可行的。

      Epic Games 的分割求和近似法将预计算分成两个单独的部分求解,再将两部分组合起来得到后文给出的预计算结果。分割求和近似法将镜面反射积分拆成两个独立的积分:

      <img src="Learn OpenGL.assets/image-20230830142900888.png" alt="image-20230830142900888" style="zoom:67%;" />

      <hr>

      卷积的==第一部分==被称为预滤波环境贴图,它==类似于辐照度图==,是预先计算的环境卷积贴图,但这次考虑了粗糙度。因为随着粗糙度的增加,参与环境贴图卷积的采样向量会更分散,导致反射更模糊,所以对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的 mipmap 中。

      例如,预过滤的环境贴图在其 5 个 mipmap 级别中存储 5 个不同粗糙度值的预卷积结果,如下图所示:

      <img src="Learn OpenGL.assets/image-20230830143324609.png" alt="image-20230830143324609" style="zoom: 33%;" />

      我们使用 Cook-Torrance BRDF 的==法线分布函数(NDF)生成采样向量及其散射强度==,该函数将法线和视角方向作为输入。由于我们在卷积环境贴图时事先不知道视角方向,因此 Epic Games 假设视角方向——也就是==镜面反射方向==——==总是等于输出采样方向ωo==,以作进一步近似。翻译成代码如下:

      ```c++
      vec3 N = normalize(w_o);
      vec3 R = N;
      vec3 V = R;

w_o:表面点的世界坐标

N:表面法线

R:反射方向

V:观察方向(在镜面反射中通常和R一致)

这样,预过滤的环境卷积就不需要关心视角方向了。这意味着当从如下图的角度观察表面的镜面反射时,得到的掠角镜面反射效果不是很好(图片来自文章《Moving Frostbite to PBR》)。然而,通常可以认为这是一个体面的妥协:

image

看完预滤波HDR环境贴图可能就能看懂这张图了。

  • 当采样用的法线方向和图中localPos的方向一致时,实际采样的值是由L计算出来的Irradiance
image

等式的==第二部分==等于镜面反射积分的 BRDF 部分。如果我们假设每个方向的入射辐射度都是白色的(因此L(p,x)=1.0 ),就可以在给定粗糙度、光线 ωi 法线 n 夹角 n⋅ωi 的情况下,预计算 BRDF 的响应结果。

Epic Games 将预计算好的 BRDF 对每个粗糙度和入射角的组合的响应结果存储在一张 2D 查找纹理(LUT)上,称为==BRDF积分贴图==。2D 查找纹理存储是==菲涅耳响应的系数(R 通道)==和==偏差值(G 通道)==,它为我们提供了分割版镜面反射积分的第二个部分:

image

生成查找纹理的时候,我们以 BRDF 的输入==n⋅ωi(范围在 0.0 和 1.0 之间)作为横坐标==,以==粗糙度作为纵坐标==。有了此 BRDF 积分贴图和预过滤的环境贴图,我们就可以将两者结合起来,以获得镜面反射积分的结果:

1
2
3
4
float lod             = getMipLevelFromRoughness(roughness);
vec3 prefilteredColor = textureCubeLod(PrefilteredEnvMap, refVec, lod);
vec2 envBRDF = texture2D(BRDFIntegrationMap, vec2(NdotV, roughness)).xy;
vec3 indirectSpecular = prefilteredColor * (F * envBRDF.x + envBRDF.y)

至此,你应该对 Epic Games 的分割求和近似法的原理,以及它如何近似求解反射方程的间接镜面反射部分有了一些基本印象。让我们现在尝试一下自己构建预卷积部分。

预滤波HDR环境贴图

分割求和近似法第一部分

  • 本质:类似间接光漫反射IBL的计算,不过这次不是在半球上随机采样积分,而是在宏观法线附近采样(N=V=R)生成预滤波HDR环境贴图。后期使用反射向量采样该贴图即可得到反射部分的Irradiance。

预滤波环境贴图的方法与我们对辐射度贴图求卷积的方法非常相似。对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的 mipmap 中。

首先,我们需要生成一个新的立方体贴图来==保存预过滤的环境贴图数据==。为了确保为其 mip 级别分配足够的内存,一个简单方法是调用 glGenerateMipmap

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned int prefilterMap;
glGenTextures(1, &prefilterMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap);
for (unsigned int i = 0; i < 6; ++i)
{
glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 128, 128, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

注意,因为我们计划采样 prefilterMap 的 mipmap,所以需要确保将其缩小过滤器设置为 GL_LINEAR_MIPMAP_LINEAR 以启用三线性过滤。

它存储的是预滤波的镜面反射,基础 mip 级别的分辨率是每面 128×128,对于大多数反射来说可能已经足够了,但如果场景里有大量光滑材料(想想汽车上的反射),可能需要提高分辨率。


在上一节教程中,我们使用球面坐标生成均匀分布在半球 Ω 上的采样向量,以==对环境贴图进行卷积==。虽然这个方法非常适用于辐照度,但对于镜面反射效果较差。镜面反射依赖于表面的粗糙度,反射光线可能比较松散,也可能比较紧密,但是==一定会围绕着反射向量r==,除非表面极度粗糙:

image

所有可能出射的反射光构成的形状称为==镜面波瓣==。随着粗糙度的增加,镜面波瓣的大小增加;随着入射光方向不同,形状会发生变化。因此,镜面波瓣的形状高度依赖于材质。

在微表面模型里给定入射光方向,则镜面波瓣指向==微平面的半向量的反射方向==。

考虑到大多数光线最终会反射到一个基于半向量的镜面波瓣内,采样时以类似的方式选取采样向量是有意义的,因为大部分其余的向量都被浪费掉了,这个过程称为==重要性采样==。

蒙特卡洛积分和重要性采样

重要性采样可参考链接:

为了充分理解重要性采样,我们首先要了解一种数学结构,称为==蒙特卡洛积分==。蒙特卡洛积分主要是统计和概率理论的组合。蒙特卡洛可以帮助我们离散地解决人口统计问题,而不必考虑所有人。

  • 例如,假设您想要计算一个国家所有公民的平均身高。为了得到结果,你可以测量每个公民并对他们的身高求平均,这样会得到你需要的确切答案。但是,由于大多数国家人海茫茫,这个方法不现实:需要花费太多精力和时间。
  • 另一种方法是选择一个小得多的完全随机(无偏)的人口子集,测量他们的身高并对结果求平均。可能只测量 100 人,虽然答案并非绝对精确,但会得到一个相对接近真相的答案,这个理论被称作==大数定律==。
  • 我们的想法是,如果从总人口中测量一组较小的真正随机样本的N,结果将相对接近真实答案,并随着样本数 N 的增加而愈加接近。

蒙特卡罗积分建立在大数定律的基础上,并采用相同的方法来求解积分。不为所有可能的(理论上是无限的)样本值 x 求解积分,而是简单地从总体中随机挑选样本 N 生成采样值并求平均。随着 N 的增加,我们的结果会越来越接近积分的精确结果:

image

为了求解这个积分,我们在 a 到 b 上采样 N 个随机样本,将它们加在一起并除以样本总数来取平均。pdf 代表==概率密度函数== (probability density function),它的含义是==特定样本在整个样本集上发生的概率==。例如,人口身高的 pdf 看起来应该像这样:

image

从该图中我们可以看出,如果我们对人口任意随机采样,那么挑选身高为 1.70 的人口样本的可能性更高,而样本身高为 1.50 的概率较低。

在人群中随机挑选一个人(均匀采样),该人身高为1.7可能性较大(分布)

采样pdf:主观决定以什么方式采样。主要影响收敛速度

分布pdf:客观存在怎样的分布。主要影响pdf(x)

当涉及蒙特卡洛积分时,某些样本可能比其他样本具有更高的生成概率。这就是为什么对于任何一般的蒙特卡洛估计,我们都会根据 pdf 将==采样值除以或乘以采样概率==。

到目前为止,我们每次需要估算积分的时候,==生成的样本==都是==均匀分布==的,概率完全相等。到目前为止,我们的==估计是无偏==的,这意味着随着样本数量的不断增加,我们最终将==收敛到积分的精确解==。

但是,某些蒙特卡洛估算是==有偏==的,这意味着==生成的样本并不是完全随机的==,而是集中于特定的值或方向。这些有偏的蒙特卡洛估算具有==更快的收敛速度==,它们会以更快的速度收敛到精确解,但是由于其有偏性,可能永远不会收敛到精确解。

通常来说,这是一个可以接受的折衷方案,尤其是在计算机图形学中。因为只要结果在视觉上可以接受,解决方案的精确性就不太重要。下文我们将会提到一种(有偏的)重要性采样,其生成的样本偏向特定的方向,在这种情况下,我们会将每个样本乘以或除以相应的 pdf 再求和。

蒙特卡洛积分在计算机图形学中非常普遍,因为它是一种以高效的离散方式对连续的积分求近似而且非常直观的方法:对任何面积/体积进行采样——例如半球 Ω ——在该面积/体积内生成数量 N 的随机采样,权衡每个样本对最终结果的贡献并求和。

蒙特卡洛积分是一个庞大的数学主题,在此不再赘述,但有一点需要提到:生成随机样本的方法也多种多样。默认情况下,每次采样都是我们熟悉的完全(伪)随机,不过==利用半随机序列的某些属性==,我们可以生成虽然是随机样本但具有一些有趣性质的样本向量。

例如,我们可以对一种名为==低差异序列==的东西进行蒙特卡洛积分,该序列生成的仍然是随机样本,但样本==分布更均匀==:

image

当使用低差异序列生成蒙特卡洛样本向量时,该过程称为==拟蒙特卡洛积分==。拟蒙特卡洛方法具有更快的==收敛速度==,这使得它对于性能繁重的应用很有用。

鉴于我们新获得的有关蒙特卡洛(Monte Carlo)和拟蒙特卡洛(Quasi-Monte Carlo)积分的知识,我们可以使用一个有趣的属性来获得更快的收敛速度,这就是==重要性采样==。

我们在前文已经提到过它,但是在镜面反射的情况下,反射的光向量被限制在镜面波瓣中,波瓣的大小取决于表面的粗糙度。既然镜面波瓣外的任何(拟)随机生成的样本与镜面积分无关,因此==将样本集中在镜面波瓣内生成是有意义的==,但代价是蒙特卡洛估算会==产生偏差==。

本质上来说,这就是==重要性采样的核心==:只在某些区域生成采样向量,该区域围绕微表面半向量,受粗糙度限制。通过将拟蒙特卡洛采样与低差异序列相结合,并使用重要性采样偏置样本向量的方法,我们可以获得很高的==收敛速度==。

感觉通过重要性采样生成的半向量,之后会和宏观法线生成一个反射半向量

因为我们求解的速度更快,所以要达到足够的近似度,我们所需要的样本更少。因此,这套组合方法甚至可以允许图形应用程序实时求解镜面积分,虽然比预计算结果还是要慢得多。

低差异序列

在本教程中,我们将使用重要性采样来预计算间接反射方程的镜面反射部分,该采样基于拟蒙特卡洛方法给出了随机的低差异序列。我们将使用的序列被称为 Hammersley 序列,Holger Dammertz 曾仔细描述过它。Hammersley 序列是基于 Van Der Corput 序列,该序列是把十进制数字的二进制表示镜像翻转到小数点右边而得。(译注:原文为 Van Der Corpus 疑似笔误,下文各处同)

给出一些巧妙的技巧,我们可以在着色器程序中非常有效地生成 Van Der Corput 序列,我们将用它来获得 Hammersley 序列,设总样本数为 N,样本索引为 i:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float RadicalInverse_VdC(uint bits) 
{
bits = (bits << 16u) | (bits >> 16u);
bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}
// ----------------------------------------------------------------------------
vec2 Hammersley(uint i, uint N)
{
return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}

GLSL 的 Hammersley 函数可以获取大小为 N 的样本集中的低差异样本 i。

image

无需位运算的 Hammersley 序列

并非所有 OpenGL 相关驱动程序都支持位运算符(例如WebGL和OpenGL ES 2.0),在这种情况下,你可能需要不依赖位运算符的替代版本 Van Der Corput 序列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>float VanDerCorpus(uint n, uint base)
>{
float invBase = 1.0 / float(base);
float denom = 1.0;
float result = 0.0;

for(uint i = 0u; i < 32u; ++i)
{
if(n > 0u)
{
denom = mod(float(n), 2.0);
result += denom * invBase;
invBase = invBase / 2.0;
n = uint(float(n) / 2.0);
}
}

return result;
>}
>// ----------------------------------------------------------------------------
>vec2 HammersleyNoBitOps(uint i, uint N)
>{
return vec2(float(i)/float(N), VanDerCorpus(i, 2u));
>}

请注意,由于旧硬件中的 GLSL 循环限制,该序列循环遍历了所有可能的 32 位,性能略差。但是如果你没有位运算符可用的话可以考虑它,它可以在所有硬件上运行。

GGX 重要性采样

有别于均匀或纯随机地(比如蒙特卡洛)在积分半球 Ω 产生采样向量,我们的采样会根据==粗糙度==,==偏向微表面的半向量的宏观反射方向==。

采样过程将与我们之前看到的过程相似:开始一个大循环,生成一个随机(低差异)序列值,用该序列值在切线空间中生成样本向量,将样本向量变换到世界空间并对场景的辐射度采样。

不同之处在于,我们现在使用低差异序列值作为输入来生成采样向量:

1
2
3
4
5
const uint SAMPLE_COUNT = 4096u;
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
}

此外,要构建采样向量,我们需要一些方法定向和偏移采样向量,以使其==朝向特定粗糙度的镜面波瓣方向==。我们可以如理论教程中所述使用 NDF,并将 GGX NDF 结合到 Epic Games 所述的球形采样向量的处理中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//得到一个世界空间下靠近宏观法线的随机方向向量(微表面半向量,越光滑越靠近,越粗糙越随机)
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
{
float a = roughness*roughness;

float phi = 2.0 * PI * Xi.x;
//光滑:a=0,θ=0,cos=1,sin=0(与宏观法线重合)
//粗糙:a=1,cos=sqrt(1-X.y)(半球表面随机采样)
float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
float sinTheta = sqrt(1.0 - cosTheta*cosTheta);

// from spherical coordinates to cartesian coordinates
vec3 H;
H.x = cos(phi) * sinTheta;
H.y = sin(phi) * sinTheta;
H.z = cosTheta;

// from tangent-space vector to world-space sample vector
vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
vec3 tangent = normalize(cross(up, N));
vec3 bitangent = cross(N, tangent);

vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
return normalize(sampleVec);
}

基于特定的粗糙度输入和低差异序列值 Xi,我们获得了一个采样向量,==该向量大体围绕着预估的微表面的半向量==(接近宏观法线向量附近,因为越靠近法线微表面H向量越密集)。注意,根据迪士尼对 PBR 的研究,Epic Games 使用了平方粗糙度以获得更好的视觉效果。

核心代码!!!

使用低差异 Hammersley 序列和上述定义的样本生成方法,我们可以最终完成预滤波器卷积着色器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform samplerCube environmentMap;
uniform float roughness;

const float PI = 3.14159265359;

float RadicalInverse_VdC(uint bits);
vec2 Hammersley(uint i, uint N);
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness);

void main()
{
vec3 N = normalize(localPos);
vec3 R = N;
vec3 V = R;

const uint SAMPLE_COUNT = 1024u;
float totalWeight = 0.0;
vec3 prefilteredColor = vec3(0.0);
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
vec2 Xi = Hammersley(i, SAMPLE_COUNT);
//生成靠近宏观法线的随机微表面半向量H
vec3 H = ImportanceSampleGGX(Xi, N, roughness);
vec3 L = normalize(2.0 * dot(V, H) * H - V); //反射公式

float NdotL = max(dot(N, L), 0.0);
if(NdotL > 0.0)
{
prefilteredColor += texture(environmentMap, L).rgb * NdotL;
totalWeight += NdotL;
}
}
prefilteredColor = prefilteredColor / totalWeight;

FragColor = vec4(prefilteredColor, 1.0);
}

输入的粗糙度随着预过滤的立方体贴图的 mipmap 级别变化(从0.0到1.0),我们根据据粗糙度预过滤环境贴图,把结果存在 prefilteredColor 里。再用 prefilteredColor 除以采样权重总和,其中对最终结果影响较小(NdotL 较小)的采样最终权重也较小。

反射公式:

  • 入射光线:AO
  • 出射光线:OB
  • 法线:N
image

令AO=-V,N=H,即可得到上述的L

image

当采样用的法线方向和图中localPos的方向一致时,实际采样的值是由L计算出来的Irradiance

捕获预过滤 mipmap 级别

剩下要做的就是让 OpenGL 在多个 mipmap 级别上以不同的粗糙度值预过滤环境贴图。有了最开始的辐照度教程作为基础,实际上很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
prefilterShader.use();
prefilterShader.setInt("environmentMap", 0);
prefilterShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
unsigned int maxMipLevels = 5;
for (unsigned int mip = 0; mip < maxMipLevels; ++mip)
{
// reisze framebuffer according to mip-level size.
unsigned int mipWidth = 128 * std::pow(0.5, mip);
unsigned int mipHeight = 128 * std::pow(0.5, mip);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
glViewport(0, 0, mipWidth, mipHeight);

float roughness = (float)mip / (float)(maxMipLevels - 1);
prefilterShader.setFloat("roughness", roughness);
for (unsigned int i = 0; i < 6; ++i)
{
prefilterShader.setMat4("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, prefilterMap, mip);

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
renderCube();
}
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

这个过程类似于辐照度贴图卷积,但是这次我们将帧缓冲区缩放到适当的 mipmap 尺寸, mip 级别每增加一级,尺寸缩小为一半。此外,我们在 glFramebufferTexture2D 的最后一个参数中指定要渲染的目标 ==mip 级别==,然后将要预过滤的粗糙度传给预过滤着色器。

这样我们会得到一张经过适当预过滤的环境贴图,访问该贴图时==指定的 mip 等级越高==,==获得的反射就越模糊==。如果我们在天空盒着色器中显示这张预过滤的环境立方体贴图,并在其着色器中强制在其第一个 mip 级别以上采样,如下所示:

1
vec3 envColor = textureLod(environmentMap, WorldPos, 1.2).rgb;

我们得到的结果看起来确实像原始环境的模糊版本:

image

如果 HDR 环境贴图的预过滤看起来差不多没问题,尝试一下不同的 mipmap 级别,观察预过滤贴图随着 mip 级别增加,反射逐渐从锐利变模糊的过程。

预过滤卷积的伪像

分割求和近似法第一部分:优化

当前的预过滤贴图可以在大多数情况下正常工作,不过你迟早会遇到几个与预过滤卷积直接相关的渲染问题。我将在这里列出最常见的一些问题,以及如何修复它们。

高粗糙度的立方体贴图接缝

在具有粗糙表面的表面上对预过滤贴图采样,也就等同于在较低的 mip 级别上对预过滤贴图采样。在对立方体贴图进行采样时,默认情况下,OpenGL不会在立方体面之间进行线性插值。由于较低的 mip 级别具有更低的分辨率,并且预过滤贴图代表了与更大的采样波瓣卷积,因此缺乏立方体的面和面之间的滤波的问题就更明显:

image

幸运的是,OpenGL 可以启用 GL_TEXTURE_CUBE_MAP_SEAMLESS,以为我们提供在立方体贴图的面之间进行正确过滤的选项:

1
glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS);  

预过滤卷积的亮点

由于镜面反射中光强度的变化大,高频细节多,所以对镜面反射进行卷积需要大量采样,才能正确反映 HDR 环境反射的混乱变化。我们已经进行了大量的采样,但是在某些环境下,在某些较粗糙的 mip 级别上可能仍然不够,导致明亮区域周围出现点状图案:

image

一种解决方案是进一步增加样本数量,但在某些情况下还是不够。另一种方案如 Chetan Jags 所述,我们可以在预过滤卷积时,不直接采样环境贴图,而是基于积分的 PDF 和粗糙度采样环境贴图的 mipmap ,以减少伪像:

1
2
3
4
5
6
7
8
float D   = DistributionGGX(NdotH, roughness);
float pdf = (D * NdotH / (4.0 * HdotV)) + 0.0001;

float resolution = 512.0; // resolution of source cubemap (per face)
float saTexel = 4.0 * PI / (6.0 * resolution * resolution);
float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001);

float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel);

既然要采样 mipmap ,不要忘记在环境贴图上开启三线性过滤:

1
2
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);

设置立方体贴图的基本纹理后,让 OpenGL 生成 mipmap:

1
2
3
4
5
// convert HDR equirectangular environment map to cubemap equivalent
[...]
// then generate mipmaps
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

这个方法效果非常好,可以去除预过滤贴图中较粗糙表面上的大多数甚至全部亮点。

预计算 BRDF*

看不懂参考GAMES202:Ch4.实时环境映射 – 环境光照 – 基本思路 – Part2

预过滤的环境贴图已经可以设置并运行,我们可以集中精力于求和近似的第二部分:BRDF。让我们再次简要回顾一下镜面部分的分割求和近似法:

image

我们已经在预过滤贴图的各个粗糙度级别上预计算了分割求和近似的左半部分。右半部分要求我们在 n⋅ωo 、表面粗糙度、菲涅尔系数 F0 上计算 BRDF 方程的卷积。这等同于在纯白的环境光或者辐射度恒定为 Li=1.0 的设置下,对镜面 BRDF 求积分。

对3个变量做卷积有点复杂,不过我们可以把 F0 移出镜面 BRDF 方程:

image

F 为菲涅耳方程。将菲涅耳分母移到 BRDF 下面可以得到如下等式:

image

用 Fresnel-Schlick 近似公式替换右边的 F 可以得到:

image

让我们用 α 替换 (1−ωo⋅h)^5^ 以便更轻松地求解 F0:

image

然后我们将菲涅耳函数 F 分拆到两个积分里:

image

这样,F0在整个积分上是恒定的,我们可以从积分中提取出F0。接下来,我们将α替换回其原始形式,从而得到最终分割求和的 BRDF 方程:

image

公式中的两个积分分别表示 F0 的比例和偏差。注意,由于 f(p,ωi,ωo) 已经包含 F 项,它们被约分了,这里的 ==f 中不计算 F 项==。


和之前卷积环境贴图类似,我们可以对 BRDF 方程求卷积,其==输入是 n 和 ωo 的夹角,以及粗糙度==,并将==卷积的结果存储在纹理中==。

我们将卷积后的结果存储在 2D 查找纹理(Look Up Texture, LUT)中,这张纹理被称为 BRDF 积分贴图,稍后会将其用于 PBR 光照着色器中,以获得间接镜面反射的最终卷积结果。

核心代码!!!

BRDF 卷积着色器在 2D 平面上执行计算,直接使用其 2D 纹理坐标作为卷积输入(NdotV 和 roughness)。代码与预滤波器的卷积代码大体相似,不同之处在于,它现在根据 BRDF 的几何函数和 Fresnel-Schlick 近似来处理采样向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
vec2 IntegrateBRDF(float NdotV, float roughness)
{
vec3 V;
V.x = sqrt(1.0 - NdotV*NdotV); //假设phi=0,可推
V.y = 0.0;
V.z = NdotV;

float A = 0.0;
float B = 0.0;

vec3 N = vec3(0.0, 0.0, 1.0);

const uint SAMPLE_COUNT = 1024u;
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
vec2 Xi = Hammersley(i, SAMPLE_COUNT); //获得2维(0,1)随机数
vec3 H = ImportanceSampleGGX(Xi, N, roughness); //在法线附近采样H
vec3 L = normalize(2.0 * dot(V, H) * H - V); //计算反射向量L

// 代入公式
float NdotL = max(L.z, 0.0);
float NdotH = max(H.z, 0.0);
float VdotH = max(dot(V, H), 0.0);

if(NdotL > 0.0)
{
float G = GeometrySmith(N, V, L, roughness);
float G_Vis = (G * VdotH) / (NdotH * NdotV);
float Fc = pow(1.0 - VdotH, 5.0);

A += (1.0 - Fc) * G_Vis;
B += Fc * G_Vis;
}
}
A /= float(SAMPLE_COUNT);
B /= float(SAMPLE_COUNT);
return vec2(A, B);
}
// ----------------------------------------------------------------------------
void main()
{
vec2 integratedBRDF = IntegrateBRDF(TexCoords.x, TexCoords.y);
FragColor = integratedBRDF;
}

如你所见,BRDF 卷积部分是从数学到代码的直接转换。我们将角度 θ 和粗糙度作为输入,以重要性采样产生采样向量,在整个几何体上结合 BRDF 的菲涅耳项对向量进行处理,然后输出每个样本上 F0 的系数和偏差,最后取平均值。

你可能回想起[理论](https://learnopengl-cn.github.io/07 PBR/01 Theory/)教程中的一个细节:与 IBL 一起使用时,BRDF 的几何项略有不同,因为 k 变量的含义稍有不同:

image

由于 BRDF 卷积是镜面 IBL 积分的一部分,因此我们要在 Schlick-GGX 几何函数中使用 kIBL:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
float GeometrySchlickGGX(float NdotV, float roughness)
{
float a = roughness;
float k = (a * a) / 2.0;

float nom = NdotV;
float denom = NdotV * (1.0 - k) + k;

return nom / denom;
}
// ----------------------------------------------------------------------------
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx2 = GeometrySchlickGGX(NdotV, roughness);
float ggx1 = GeometrySchlickGGX(NdotL, roughness);

return ggx1 * ggx2;
}

请注意,虽然 kk 还是从 a 计算出来的,但这里的 a 不是 roughness 的平方——如同最初对 a 的其他解释那样——在这里我们假装平方过了。我不确定这样处理是否与 Epic Games 或迪士尼原始论文不一致,但是直接将 roughness 赋给 a 得到的 BRDF 积分贴图与 Epic Games 的版本完全一致。


最后,为了存储 BRDF 卷积结果,我们需要生成一张 512 × 512 分辨率的 2D 纹理。

1
2
3
4
5
6
7
8
9
10
unsigned int brdfLUTTexture;
glGenTextures(1, &brdfLUTTexture);

// pre-allocate enough memory for the LUT texture.
glBindTexture(GL_TEXTURE_2D, brdfLUTTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16F, 512, 512, 0, GL_RG, GL_FLOAT, 0);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

请注意,我们使用的是 Epic Games 推荐的16位精度浮点格式。将环绕模式设置为 GL_CLAMP_TO_EDGE 以防止边缘采样的伪像。 然后,我们复用同一个帧缓冲区对象,并在 NDC (译注:Normalized Device Coordinates) 屏幕空间四边形上运行此着色器:

1
2
3
4
5
6
7
8
9
10
11
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, brdfLUTTexture, 0);

glViewport(0, 0, 512, 512);
brdfShader.use();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
RenderQuad();

glBindFramebuffer(GL_FRAMEBUFFER, 0);
image

预过滤的环境贴图和 BRDF 的 2D LUT 都已经齐备,我们可以根据分割求和近似法重建间接镜面部分积分了。最后合并的结果将被用作间接镜面反射或环境镜面反射。

完成 IBL 反射

为了使反射方程的间接镜面反射部分正确运行,我们需要将分割求和近似法的两个部分缝合在一起。第一步是将预计算的光照数据声明到 PBR 着色器的最上面:

1
2
uniform samplerCube prefilterMap;
uniform sampler2D brdfLUT;

首先,==使用反射向量采样==预过滤的环境贴图,获取表面的间接镜面反射。请注意,我们会根据表面粗糙度在合适的 mip 级别采样,以使更粗糙的表面产生更模糊的镜面反射。

1
2
3
4
5
6
7
8
9
void main()
{
[...]
vec3 R = reflect(-V, N);

const float MAX_REFLECTION_LOD = 4.0;
vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;
[...]
}

在预过滤步骤中,我们仅将环境贴图卷积最多 5 个 mip 级别(0到4),此处记为 MAX_REFLECTION_LOD,以确保不会对一个没有数据的 mip 级别采样。 然后我们用已知的==材质粗糙度==和==视线-法线夹角==作为输入,==采样 BRDF LUT==。

1
2
3
vec3 F        = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

这样我们就从 BRDF LUT 中获得了 F0 的系数和偏移,这里我们就直接用间接光菲涅尔项 F 代替F0。把这个结果和 IBL 反射方程左边的预过滤部分结合起来,以重建整个近似积分,存入specular。

于是我们得到了反射方程的间接镜面反射部分。


现在,将其与[上一节教程](https://learnopengl-cn.github.io/07 PBR/03 IBL/01 Diffuse irradiance/)中的反射方程的漫反射部分结合起来,我们可以获得完整的 PBR IBL 结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
vec3 F = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);

vec3 kS = F;
vec3 kD = 1.0 - kS;
kD *= 1.0 - metallic;

vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse = irradiance * albedo;

const float MAX_REFLECTION_LOD = 4.0;
vec3 prefilteredColor = textureLod(prefilterMap, R, roughness * MAX_REFLECTION_LOD).rgb;
vec2 envBRDF = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

vec3 ambient = (kD * diffuse + specular) * ao;

请注意,specular 没有乘以 kS,因为已经乘过了菲涅耳系数。 现在,在一系列粗糙度和金属度各异的球上运行此代码,我们终于可以在最终的 PBR 渲染器中看到其真实颜色:

image

我们甚至可以再疯狂一点,使用一些带酷炫纹理的 PBR 材质

image

或加载 Andrew Maximov 的这款出色的免费 PBR 3D 模型

image

我敢肯定我们都同意现在的光照看起来更具说服力。更妙的是,无论我们使用哪种环境贴图,我们的光照看起来都是物理正确的。

下面,您将看到几张不同的预计算 HDR 贴图,它们完全改变了光照动态,但是不需要调整任何光照变量,在外观上依然正确!

image

下一步是?

希望在本教程结束时,你会对 PBR 的相关内容有一个清晰的了解,甚至可以构造并运行一个实际的 PBR 渲染器。在这几节教程中,我们已经在应用程序开始阶段,渲染循环之前,预计算了所有 PBR 相关的基于图像的光照数据。

出于教育目的,这很好,但对于任何 PBR 的实践应用来说,都不是很漂亮。首先,预计算实际上只需要执行一次,而不是每次启动时都要做。其次,当使用多个环境贴图时,你必须在每次程序启动时全部预计算一遍,这是个必须步骤。

因此,通常只需要一次将环境贴图预计算为辐照度贴图和预过滤贴图,然后将其存储在磁盘上(注意,BRDF 积分贴图不依赖于环境贴图,因此只需要计算或加载一次)。这意味着您需要提出一种自定义图像格式来存储 HDR 立方体贴图,包括其 mip 级别。或者将图像存储为某种可用格式——例如支持存储 mip 级别的 .dds——并按其格式加载。

此外,我们也在教程中描述了整个过程,包括生成预计算的 IBL 图像,以帮助我们进一步了解 PBR 管线。此外还可以通过 cmftStudioIBLBaker 等一些出色的工具为您生成这些预计算贴图,也很好用。

有一点内容我们跳过了,即如何将预计算的立方体贴图作为反射探针:立方体贴图插值和视差校正。这是一个在场景中放置多个反射探针的过程,这些探针在特定位置拍摄场景的立方体贴图快照,然后我们可以将其卷积,作为相应部分场景的 IBL 数据。基于相机的位置对附近的探针插值,我们可以实现局部的细节丰富的 IBL,受到的唯一限制就是探针放置的数量。这样一来,例如从一个明亮的室外部分移动到较暗的室内部分时,IBL 就能正确更新。我将来会在某个地方编写有关反射探针的教程,但现在,我建议阅读下面 Chetan Jags 的文章来作为入门。

进阶阅读

 评论