入门

OpenGL可以被看作是一个大的状态机。API中的一些函数会根据当前OpenGL的状态的不同,而产生不同的效果,这些函数被称为状态函数。

OpenGL的工作流:

创建对象->绑定对象到上下文(Bind Gen出来的Object到OpenGL上下文的内置属性)->设置已绑定对象的选项->解绑对象

PS:解绑对象只是让对象和上下文之间断开联系。实际上选项已经改变,与解绑无关。当要获取之前那个对象的信息时,只需要重新绑定那个上下文变量就行了。比如说我们有一些作为3D模型数据(一栋房子或一个人物)的容器对象,在我们想绘制其中任何一个模型的时候,只需绑定一个包含对应模型数据的对象就可以了。拥有数个这样的对象允许我们指定多个模型,在想画其中任何一个的时候,直接将对应的对象绑定上去,便不需要再重复设置选项了。

// 创建对象
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);

环境配置

GLFW

下载源代码,使用Clion打开源代码根目录作为项目。

点击构建-构建项目,完成后在cmake-build-output/src文件夹下找到glfw3.dll文件。

新建空项目,新建libs和include文件夹,将GLFW源代码中include文件夹的内容拖入新include文件夹,将编译完成的glfw3.dll拖入libs文件夹。同时,也要把glfw3.dll拖入cmake-build-output文件夹下

修改CMakeList.txt内容如下:

cmake_minimum_required(VERSION 3.28)
project(LearnOpenGL) # 定义项目名

set(CMAKE_CXX_STANDARD 17)

add_executable(LearnOpenGL main.cpp) # 这步必须放在链接操作之前

INCLUDE_DIRECTORIES(include) # include文件所在路径
link_directories(libs) # libs文件所在路径
target_link_libraries(LearnOpenGL libs/glfw3.dll) # dll文件所在路径

重新加载CMake即可。可新建cpp文件,输入#include <GLFW\glfw3.h>,若未报错则链接成功。

GLAD

打开http://glad.dav1d.de/,按照以下规则配置:

Language:C/C++

Specification:OpenGL

API-gl:3.3 and newer

Opentions - Generate a loader:打勾

点击Generate按钮,下载glad.zip,解压,将include内的文件拖动到工程的include文件里,glad.c则放到工程根目录。使用#include <glad\glad.h>验证是否配置成功。

范例工程

头文件

#include <glad/glad.h>
#include <GLFW/glfw3.h>

注意:glad头文件必须在GLFW之前include

代码

#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
using namespace std;

//处理键盘输入,当用户按下ESC键时,提醒程序要退出
void processInput(GLFWwindow *window)
{
    //glfwGetKey函数用于检查一个键是否正在被按下
    if(glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
        glfwSetWindowShouldClose(window, true);
}

//回调函数,使视口范围随窗口大小变化。
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
    glViewport(0, 0, width, height);
}

int main(){
    // -----------glfw初始化与窗口创建-----------
    // 初始化glfw组件
    glfwInit();
    //指定opengl版本为3.3
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    //告诉glfw我们使用的是核心模式
    //核心模式意味着我们只能使用OpenGL功能的核心功能子集
    glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);
    //创建glfw窗口
    //第一个参数是窗口的宽度,第二个是高度,第三个是窗口的标题,第四个参数指定窗口是否共享资源,第五个参数是共享资源的窗口
    //共享资源指的是多个窗口可以共享同一个上下文
    GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
    //检查窗口是否创建成功
    if (window == NULL)
    {
        cout << "Failed to create GLFW window" << endl;
        //销毁glfw
        glfwTerminate();
        return -1;
    }
    //通知glfw将我们窗口的上下文设置为当前线程的主上下文
    glfwMakeContextCurrent(window);
    // -----------GLAD加载所有OpenGL函数指针-----------
    if(!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)){
        cout << "Failed to initialize GLAD" << endl;
        return -1;
    }
    //先前只是生成了窗口的大小,但窗口大小与实际渲染区域无关。因此,我们需要定义渲染区域的大小,即视口(Viewport)。
    //视口定义了窗口中可以渲染的区域,将其设置为窗口的维度
    //前两个参数代表渲染区域左下角在GLFW窗口的坐标。第三个和第四个参数是渲染区域的宽度和高度(像素)
    //注意,这里并没有传入window,所以glViewport是个状态使用函数。
    glViewport(0, 0, 800, 600);
    //为了让窗口被改变时,视口也能相应改变,我们需要注册一个窗口大小的回调函数
    //glfwSetFramebufferSizeCallback函数接受一个窗口,一个函数指针,当窗口大小改变时调用这个函数
    glfwSetFramebufferSizeCallback(window,framebuffer_size_callback);
    // -----------渲染循环-----------
    //当窗口被要求关闭时,glfwWindowShouldClose函数返回true
    while(!glfwWindowShouldClose(window)){
        // 清除颜色缓冲
        // glClearColor用来设置清空屏幕所用的颜色
        glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
        // 清除颜色缓冲。除此之外还有GL_DEPTH_BUFFER_BIT,GL_STENCIL_BUFFER_BIT,分别用于清除深度缓冲、模板缓冲
        // glClearColor是状态设置函数,而glClear是状态使用函数
        glClear(GL_COLOR_BUFFER_BIT);
        // 调用输入函数,处理键盘输入
        processInput(window);
        //
        // 这里放渲染指令...
        //
        // 交换缓冲区(双缓冲区的实现, 用于避免图像闪烁)
        glfwSwapBuffers(window);
        // 处理事件,比如键盘和鼠标事件,随后更新窗口状态并调用回调函数
        glfwPollEvents();
    }
    // -----------清理资源-----------
    glfwDestroyWindow(window);
    glfwTerminate();
    return 0;
}

通常的OpenGL范例程序:

初始化GLFW->设置GLFW信息,如OpenGL版本号->生成窗口->设置线程上下文->使用GLAD获取GL函数->定义渲染视口大小->注册各类回调函数->进入渲染循环->销毁资源

渲染循环内部:

清空缓存->处理输入->进行渲染操作(在后缓冲上绘制)->检查并调用事件,交换缓冲(使后缓冲的内容显示到画面上)

GLFW函数可分为两类:状态设置函数和状态使用函数。前者用于设置状态量,后者用于借助已经设置完毕的状态来改变程序行为。

对象绘制

基础

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

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

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

图形渲染管线可以被分为两个部分:

  • 第一部分负责将3D坐标转换为2D坐标
  • 第二部分负责将2D坐标转换为有颜色的,实际屏幕上的像素。

2D坐标与像素不同。像素是2D坐标的近似值,受分辨率影响。

着色器是运行在GPU上的处理程序。每个小核心负责一个着色器的计算。

Vertex Shader、Geometry Shader和Fragment Shader可以由开发者自定义。

img

Vertex Shader把单独的顶点作为输入,将局部坐标系下的顶点坐标转换到标准化设备坐标(NDC),同时对顶点属性(Vertex Attribute)进行基本处理。

经Vertex Shader处理过的坐标必定是NDC,范围为[-1, 1]

Geometry Shader把一个图元(包括点、线、三角形三种类型)的顶点作为输入,根据需要处理这些顶点,也可以生成新的顶点,用于构建新的形状。

这个过程可选。

Shape Assembly将前阶段的所有顶点作为输入,并将其装配为指定图元的形状。

Rasterization把图元映射为屏幕上的像素,生成Fragment, 并剔除View以外的所有像素。

glViewport函数定义了视口信息。视口变换(Viewport Transform)将NDC变换为屏幕空间坐标。

屏幕空间坐标被变换为Fragment,输入到Fragment Shader中。

Fragment Shader用于计算一个像素的最终颜色。该阶段包含3D场景的数据,如光照、阴影等。

Test and Blending阶段,首先检测所有像素的深度值、模板值,用于判断像素是正面还是背面,并据此决定是否丢弃。随后,根据像素的alpha值,进行blend操作。

顶点输入

顶点数据首先被送到顶点着色器。这些数据以顶点缓冲对象(Vertex Buffer Objects, VBO)的状态存储在GPU内存(即显存)中。

使用VBO的优点在于,可以一次发送一大批数据到GPU上,而非一个顶点传送一次。

CPU到GPU的传输速度较慢,所以要尽量减少传输次数,一次发送尽可能多的数据。

通过glGenBuffers(int count, unsigned int* VBO)函数生成VBO对象。生成完毕后,变量VBO将存储VBO实例的id。随后,进行绑定操作。

unsigned int VBO;
glGenBuffers(1, &VBO); //生成一个VBO对象,带有缓冲区id
glBindBuffer(GL_ARRAY_BUFFER, VBO);  //将该对象绑定到GL上下文的GL_ARRAY_BUFFER目标

任何对象在生成以后都需要与GL上下文中的特定目标进行绑定,才能生效。

每个缓冲区目标都只能同时绑定一个对象。

GL_ARRAY_BUFFER存储的对象通常是:需要在CPU和GPU之间传输的顶点相关数据。

glBufferData(CONTEXT_TARGET, int data_len, float[] data, DRAW_FORM)用于向当前绑定的缓冲区存入用户定义数据。

float verticals[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};
glBufferData(GL_ARRAY_BUFFER, sizeof(verticals),verticals, GL_STATIC_DRAW);

DRAW_FORM参数用于指定显卡如何管理存入的数据。

  • GL_STATIC_DRAW:存入的数据几乎不会发生变化
  • GL_DYNAMIC_DRAW:存入的数据时常会有发生变化
  • GL_STREAM_DRAW:存入的数据每时每刻都在变化

至此,顶点已完成了输入,此刻的顶点数据以VBO的形式存储在显存中。

顶点着色器

#version 330 core //定义版本号和PROFILE模式
//定义一个名为aPos的输入变量,其位置索引为0,类型为vec3。
layout (location = 0) in vec3 aPos;
void main()
{
    //将输入的顶点位置转换为4维向量(齐次坐标),并赋值给内建变量gl_Position,该变量表示顶点着色器的输出位置。
    gl_Position = vec4(aPos, 1.0);
}

location变量用于绑定顶点属性的特定位置索引。一般而言,顶点属性都是用float类型存储的。而存放顶点数据的vertical数组又是一维而非二维的。因此,可能出现前N个数据里,数据[0,N-M]是位置数据,而[N-M+1,N]是颜色数据。通过设置不同的location变量,可以解明顶点数据的具体含义。

gl_Position的值将会成为顶点着色器的输出。

在实际的顶点着色器中,往往还需要经过坐标变换到NDC的过程。

C++源码文件无法直接嵌入GLSL代码。所以需要在运行时动态编译。

//顶点着色器源码
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";
//用于保存着色器对象ID引用的无符号整数
unsigned int vertexShader;
//使用glCreateShader函数创建指定类型的着色器对象
//由于同种类型的着色器只能生成一个,所以可以直接赋值而非传入地址
vertexShader = glCreateShader(GL_VERTEX_SHADER);
//对于glShaderSource,其参数含义分别为:着色器对象、源码字符串数量、源码字符串首地址、包含每个字符串长度的整数数组。该函数是把“源码字符串”绑定到“着色器对象”的操作。
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
//进行编译
glCompileShader(vertexShader);

通过glGetShaderiv函数可以检测编译是否成功。

int  success; //是否成功编译的flag
char infoLog[512]; //错误信息字符数组
//glGetShaderiv用于获取着色器信息
//第二个参数可替换为以下选项:
//GL_SHADER_TYPE:获取着色器类型
//..待补充
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success); //获取编译结果
if(!success)
{ //若未编译成功
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog); //获取编译错误信息,512代表infoLog数组大小
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl; //打印
}

片元着色器

片元着色器用于计算像素最后的颜色输出。

#version 330 core
out vec4 FragColor; // 片元着色器允许用户自定义输出变量
void main()
{
    FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f); //橘黄色
} 

随后进行编译。

const char* fragmentShaderSource = "#version 330 core\n"
    "out vec4 FragColor;\n"
    "void main()\n"
    "{\n"
    "FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);\n"
    "}\n";
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

着色器程序

完成着色器编译后,还需要编写着色器程序。整个渲染管线就像一个链表,着色器是其中的一个个节点,而着色器程序负责把这些节点连接(Link)起来,并负责数据的输入输出。

当链接着色器至一个程序的时候,它会把每个着色器的输出链接到下个着色器的输入。当输出和输入不匹配的时候,你会得到一个连接错误。

unsigned int shaderProgram; //句柄
shaderProgram = glCreateProgram();
//注意Attach操作的顺序
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
//完成Attach以后,进行Link操作。
glLinkProgram(shaderProgram);

借助glGetProgramiv函数,可以判断链接是否出错。

glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
if(!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    ...
}

链接完毕后,使用glUseProgram函数激活着色器程序对象,同时,删除先前定义的着色器,以释放内存。

glUseProgram(shaderProgram);
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

完成这一系列操作以后,我们完成了以下内容:

  • 发送顶点数据,让其以VBO的状态存储在显存。
  • 编写了Vertex Shader和Fragment Shader,并指示GPU该如何使用这些着色器处理顶点数据。

链接顶点属性

我们输入的verticals数组是一个一维float数组。在这个数组里,每3个元素代表着一个顶点的位置数据。但OpenGL不知道,所以我们要告诉OpenGL,数组的哪些位置代表着哪个顶点的什么属性。

//指定解析顶点数据的方式
//第一个参数指定我们要配置的顶点属性,即着色器中的location。位置信息是一个location,颜色信息就是另一个location
//第二个参数指定顶点数据的大小,也就是每个属性的维度数。位置数据是一个三维向量,所以输入3
//第三个参数指定数据的类型
//第四个参数指定是否归一化,映射到0-1(或-1到1,对于有符号数来说。)
//第五个参数指定步长,即:这个属性第二次出现的地方到整个数组0位置之间有多少字节。对于单一属性数组,可设置为0
//第六个参数表示数据在缓冲区相对于起始位置的偏移量。如:位置信息包含3字节,紧随其后的是颜色信息。那么对于位置信息,这个参数就是(void*)0,对于颜色信息就是(void*)3
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
//设置完成后,启用顶点属性
glEnableVertexAttribArray(0);
//随后启用着色器程序
glUseProgram(shaderProgram);

顶点数组对象

所以,每次绘制一个物体,我们都必须经历以下步骤:

  • 生成VBO对象
  • 绑定VBO对象到GL_ARRAY_BUFFER
  • 将顶点数据(float数组)传入GL_ARRAY_BUFFER
  • 设置顶点属性指针
  • 启用顶点属性
  • 使用着色器程序
  • 绘制物体

非常繁琐。为了减少工作量,我们引入顶点数组对象(Vertex Array Object,VAO)的概念。

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

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

一般,完整的渲染代码可以表示为:

// ..:: 初始化代码(只运行一次 (除非你的物体频繁改变)) :: ..
// 生成VAO、VBO对象....
unsigned int VAO;
glGenVertexArrays(1, &VAO);
unsigned int VBO;
glGenVertexArrays(1, &VBO);
// 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();

绘制

glDrawArray函数使用当前激活的着色器和VAO(包含VBO信息)来绘制图元(点、线、三角)。

glUseProgram(shaderProgram);
glBindVertexArray(VAO);
// 第一个参数表示图元类型
// 第二个参数表示顶点数组起始索引
// 第三个表示绘制的顶点数量
glDrawArrays(GL_TRIANGLES, 0, 3);

完整代码

#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
using namespace std;

//顶点数据
float verticals[] = {
        -0.5f, -0.5f, 0.0f,
        0.5f, -0.5f, 0.0f,
        0.0f,  0.5f, 0.0f
};

//顶点着色器源码
const char* vertex_shader="#version 330 core\n"
                          "layout(location=0) in vec3 aPos;\n"
                          "void main()\n"
                          "{\n"
                          "gl_Position = vec4(aPos,1.0);"
                          "}\n";

//片段着色器源码
const char* fragment_shader="#version 330 core\n"
                            "out vec4 Fragcolor;\n"
                            "void main()\n"
                            "{\n"
                            "Fragcolor = vec4(1.0f, 0.5f, 0.2f, 1.0f);"
                            "}\n";

//输入处理函数
void process_input(GLFWwindow* window, int width, int height){
    glViewport(0,0,width,height);
}

int main(){
    //初始化glfw并配置版本和PROFILE模式
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR,3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR,3);
    glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);
    //创建glfw窗口
    GLFWwindow* window = glfwCreateWindow(800,600,"LearnOpenGL",NULL,NULL);
    //创建错误处理
    if(window==NULL){
        cout<<"Failed to create GLFW Window!"<<endl;
        glfwTerminate();
        return -1;
    }
    //将窗口上下文设置为当前线程上下文
    glfwMakeContextCurrent(window);
    //加载glad函数
    if(!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)){
        cout<<"Failed to load proc"<<endl;
        return -1;
    }
    //定义视口大小
    glViewport(0,0,800,600);
    //配置窗口大小改变的回调函数,使得窗口改变时视口随之改变
    glfwSetFramebufferSizeCallback(window,process_input);
    //创建、绑定VBO对象
    unsigned int VBO;
    glGenBuffers(1,&VBO);
    glBindBuffer(GL_ARRAY_BUFFER,VBO);
    glBufferData(GL_ARRAY_BUFFER,sizeof(verticals),verticals,GL_STATIC_DRAW);
    //创建、绑定VAO对象
    unsigned int VAO;
    glGenVertexArrays(1,&VAO);
    glBindVertexArray(VAO);
    //配置顶点属性指针,说明顶点数据与属性的对应关系
    glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,0,(void*)0);
    //启用顶点属性
    glEnableVertexAttribArray(0);
    //创建顶点着色器
    unsigned int vertex = glCreateShader(GL_VERTEX_SHADER);
    //链接着色器对象与源代码
    glShaderSource(vertex,1,&vertex_shader,NULL);
    //编译着色器
    glCompileShader(vertex);
    int  success; //是否成功编译的flag
    char infoLog[512]; //错误信息字符数组
    glGetShaderiv(vertex, GL_COMPILE_STATUS, &success); //获取编译结果
    if(!success)
    { //若未编译成功
        glGetShaderInfoLog(vertex, 512, NULL, infoLog); //获取编译错误信息,512代表infoLog数组大小
        std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED\n" << infoLog << std::endl; //打印
    }
    //同上
    unsigned int fragment = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragment,1,&fragment_shader,NULL);
    glCompileShader(fragment);
    glGetShaderiv(fragment, GL_COMPILE_STATUS, &success); //获取编译结果
    if(!success)
    { //若未编译成功
        glGetShaderInfoLog(fragment, 512, NULL, infoLog); //获取编译错误信息,512代表infoLog数组大小
        std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED\n" << infoLog << std::endl; //打印
    }
    //创建着色器程序
    unsigned int shader_program = glCreateProgram();
    //添加着色器“节点”,注意顺序
    glAttachShader(shader_program,vertex);
    glAttachShader(shader_program,fragment);
    //完成链接操作
    glLinkProgram(shader_program);
    //激活着色器程序
    glUseProgram(shader_program);
    //删除着色器对象,释放内存
    glDeleteShader(vertex);
    glDeleteShader(fragment);
    //渲染循环
    while(!glfwWindowShouldClose(window)){
        //设置清空颜色
        glClearColor(0.2f,0.3f,0.3f,1.0f);
        //清空上一帧
        glClear(GL_COLOR_BUFFER_BIT);
        //激活着色器程序
        glUseProgram(shader_program);
        //绑定VAO对象
        glBindVertexArray(VAO);
        //绘制三个顶点的三角形图元
        glDrawArrays(GL_TRIANGLES,0,3);
        //交换前后缓冲
        glfwSwapBuffers(window);
        //处理外部输入
        glfwPollEvents();
    }
}

元素缓冲对象

当图元存在共用顶点的情况时,传统的绘制方法会把共用的顶点绘制两次,导致额外开销。

为此,元素缓冲对象(Element Buffer Object, EBO)提供了一种方式,用于存储OpenGL用来决定要绘制哪些顶点的索引。

使用EBO时,顶点数据必须是不重复的顶点。

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  // 第二个三角形
};
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
//渲染循环内...
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0); //注意,使用EBO时需要使用glDrawElements。其中,第四个参数表示EBO中的偏移量。

使用EBO的完整代码如下:

#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
using namespace std;

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, 1, 3, // 第一个三角形
        1, 2, 3  // 第二个三角形
};

//顶点着色器源码
const char* vertex_shader="#version 330 core\n"
                          "layout(location=0) in vec3 aPos;\n"
                          "void main()\n"
                          "{\n"
                          "gl_Position = vec4(aPos,1.0);"
                          "}\n";

//片段着色器源码
const char* fragment_shader="#version 330 core\n"
                            "out vec4 Fragcolor;\n"
                            "void main()\n"
                            "{\n"
                            "Fragcolor = vec4(1.0f, 0.5f, 0.2f, 1.0f);"
                            "}\n";

void process_input(GLFWwindow* window,int width, int height){
    glViewport(0,0,width,height);
}

int main(){
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR,3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR,3);
    glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);
    GLFWwindow* window = glfwCreateWindow(800,600,"LearnOpenGL",NULL,NULL);
    if(window==NULL){
        cout<<"Failed to Create Window"<<endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    if(!gladLoadGLLoader((GLADloadproc) glfwGetProcAddress)){
        cout<<"Failed to Load GLAD proc"<<endl;
        glfwTerminate();
        return -1;
    }
    glViewport(0,0,800,600);
    glfwSetFramebufferSizeCallback(window,process_input);

    //编译着色器
    unsigned int vertex = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertex,1,&vertex_shader,NULL);
    glCompileShader(vertex);
    int success;
    char info[512];
    glGetShaderiv(vertex,GL_COMPILE_STATUS,&success);
    if(!success){
        glad_glGetShaderInfoLog(vertex,512,NULL,info);
        cout<<"VERTEX SHADER COMPILE ERROR:"<<info<<endl;
    }
    unsigned int fragment = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragment,1,&fragment_shader,NULL);
    glCompileShader(fragment);
    glGetShaderiv(fragment,GL_COMPILE_STATUS,&success);
    if(!success){
        glad_glGetShaderInfoLog(fragment,512,NULL,info);
        cout<<"FRAGMENT SHADER COMPILE ERROR:"<<info<<endl;
    }
    unsigned int shader_program = glCreateProgram();
    glAttachShader(shader_program,vertex);
    glAttachShader(shader_program,fragment);
    glLinkProgram(shader_program);
    glGetProgramiv(shader_program, GL_LINK_STATUS, &success);
    if (!success) {
        glGetProgramInfoLog(shader_program, 512, NULL, info);
        std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED\n" << info << std::endl;
    }
    glDeleteShader(vertex);
    glDeleteShader(fragment);

    //定义VAO、VBO、EBO
    unsigned int VBO;
    glGenBuffers(1,&VBO);
    glBindBuffer(GL_ARRAY_BUFFER,VBO);
    glBufferData(GL_ARRAY_BUFFER,sizeof(vertices),vertices,GL_STATIC_DRAW);
    unsigned int VAO;
    glGenVertexArrays(1,&VAO);
    glBindVertexArray(VAO);
    glVertexAttribPointer(0,3,GL_FLOAT,GL_FALSE,0,(void*)0);
    glEnableVertexAttribArray(0);
    unsigned int EBO;
    glGenBuffers(1,&EBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER,sizeof(indices),indices,GL_STATIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
    glBindVertexArray(0);
    while(!glfwWindowShouldClose(window)){
        glClearColor(0.5f,0.4f,0.3f,1.0f);
        glClear(GL_COLOR_BUFFER_BIT);
        glBindVertexArray(VAO);
        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER,EBO);
        glUseProgram(shader_program);
        glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);
        glBindVertexArray(0);
        glBindBuffer(GL_ARRAY_BUFFER,0);
        glfwSwapBuffers(window);
        glfwPollEvents();
    }
    glDeleteBuffers(1,&VBO);
    glDeleteProgram(shader_program);
    glDeleteVertexArrays(1,&VAO);
    glfwTerminate();
    return 0;
}

正确的操作顺序如下:

  1. 绑定VAO
  2. 绑定VBO
  3. 配置顶点属性
  4. 启用顶点属性
  5. 绑定EBO
  6. 解绑VAO

着色器

GLSL

典型着色器程序的结构如下:

#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 Shader,输入变量被称为Vertex Attribute。在OpenGL中一般至少能声明16个Vertex Attribute,每个含4个分量。

向量

向量是GLSL中最常用的数据类型。包含vecnbvecnivecnuvecndvecn。前缀代表分量的基本类型,后缀n代表维度数。一般使用vecn

使用.x.y.z.w来获取它们的第1、2、3、4个分量。GLSL也允许对颜色使用rgba,或是对纹理坐标使用stpq访问相同的分量。

与CG类似,可以通过重组(Swizzling)的方式填充向量分量。

vec2 someVec;
vec4 differentVec = someVec.xyxx;
vec3 anotherVec = differentVec.zyw;
vec4 otherVec = someVec.xxxx + anotherVec.yxzy;

与此同时,还可以使用向量构造函数直接给向量变量复制,如vec2 vect = vec2(0.5, 0.7)

输入输出

inout关键字用于定义着色器的输入和输出。只要一个输出变量与下一个着色器阶段的输入匹配,它就会传递下去(前一阶段的输出变量的变量名,应当与后阶段的输入变量的变量名相同)。但在顶点和片段着色器中会有点不同。

对于Vertex Shader,其特殊点在于location关键字。location定义了着色器从顶点数据的哪一部分接收数据。例如,在Vertex Shader中,我定义了两个vec4,第一个是位置数据aPos,它的location是0;第二个是颜色数据aCol,它的location是1。

使用layout (location = 0)定义某输入变量的location

对于Fragment Shader,应当始终保证存在一个vec4型输出变量,用于输出最终颜色。

Uniform

Uniform用于在cpp程序中向着色器输入数据,改变其表现。

如其名,Uniform在每个着色器程序中都是独一无二的。在这个Program链接的所有Shader中,只能存在一个相同名称的Uniform

使用uniform关键字定义Uniform变量。

uniform vec4 ourColor;

在cpp程序中改变uniform的代码如下:

float timeValue = glfwGetTime(); //获取当前程序运行的秒数
float greenValue = (sin(timeValue) / 2.0f) + 0.5f;
//使用glGetUniformLocation函数,在shaderProgram程序中获取名为"outColor"的Uniform变量位置
int vertexColorLocation = glGetUniformLocation(shaderProgram, "ourColor");
glUseProgram(shaderProgram);
//获取位置后,使用glUniform4f函数,以之前获取的位置为参数,设置它的值。
glUniform4f(vertexColorLocation, 0.0f, greenValue, 0.0f, 1.0f);

更新uniform值时,必须Use它所在的Program,否则更新无效。

除glUniform4f外,还有glUniform3i(ivec3)、glUniformfv(float[]或vecn)、glUniformui(unsigned int)等。

多顶点属性

考虑如下顶点数据:

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(location = x)来标记不同的顶点属性,通过gVertexAttribPointer来告诉程序该如何处理这些不同的属性。

// 位置属性
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);

自定义着色器类

#ifndef SHADER_H
#define SHADER_H

#include <glad/glad.h>

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

class Shader
{
public:
    unsigned int ID;
    // constructor generates the shader on the fly
    // ------------------------------------------------------------------------
    Shader(const char* vertexPath, const char* fragmentPath)
    {
        // 1. retrieve the vertex/fragment source code from filePath
        std::string vertexCode;
        std::string fragmentCode;
        std::ifstream vShaderFile;
        std::ifstream fShaderFile;
        // ensure ifstream objects can throw exceptions:
        vShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
        fShaderFile.exceptions (std::ifstream::failbit | std::ifstream::badbit);
        try
        {
            // open files
            vShaderFile.open(vertexPath);
            fShaderFile.open(fragmentPath);
            std::stringstream vShaderStream, fShaderStream;
            // read file's buffer contents into streams
            vShaderStream << vShaderFile.rdbuf();
            fShaderStream << fShaderFile.rdbuf();
            // close file handlers
            vShaderFile.close();
            fShaderFile.close();
            // convert stream into string
            vertexCode   = vShaderStream.str();
            fragmentCode = fShaderStream.str();
        }
        catch (std::ifstream::failure& e)
        {
            std::cout << "ERROR::SHADER::FILE_NOT_SUCCESSFULLY_READ: " << e.what() << std::endl;
        }
        const char* vShaderCode = vertexCode.c_str();
        const char * fShaderCode = fragmentCode.c_str();
        // 2. compile shaders
        unsigned int vertex, fragment;
        // vertex shader
        vertex = glCreateShader(GL_VERTEX_SHADER);
        glShaderSource(vertex, 1, &vShaderCode, NULL);
        glCompileShader(vertex);
        checkCompileErrors(vertex, "VERTEX");
        // fragment Shader
        fragment = glCreateShader(GL_FRAGMENT_SHADER);
        glShaderSource(fragment, 1, &fShaderCode, NULL);
        glCompileShader(fragment);
        checkCompileErrors(fragment, "FRAGMENT");
        // shader Program
        ID = glCreateProgram();
        glAttachShader(ID, vertex);
        glAttachShader(ID, fragment);
        glLinkProgram(ID);
        checkCompileErrors(ID, "PROGRAM");
        // delete the shaders as they're linked into our program now and no longer necessary
        glDeleteShader(vertex);
        glDeleteShader(fragment);
    }
    // activate the shader
    // ------------------------------------------------------------------------
    void use()
    {
        glUseProgram(ID);
    }
    // utility uniform functions
    // ------------------------------------------------------------------------
    void setBool(const std::string &name, bool value) const
    {
        glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
    }
    // ------------------------------------------------------------------------
    void setInt(const std::string &name, int value) const
    {
        glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
    }
    // ------------------------------------------------------------------------
    void setFloat(const std::string &name, float value) const
    {
        glUniform1f(glGetUniformLocation(ID, name.c_str()), value);
    }

private:
    // utility function for checking shader compilation/linking errors.
    // ------------------------------------------------------------------------
    void checkCompileErrors(unsigned int shader, std::string type)
    {
        int success;
        char infoLog[1024];
        if (type != "PROGRAM")
        {
            glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
            if (!success)
            {
                glGetShaderInfoLog(shader, 1024, NULL, infoLog);
                std::cout << "ERROR::SHADER_COMPILATION_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
            }
        }
        else
        {
            glGetProgramiv(shader, GL_LINK_STATUS, &success);
            if (!success)
            {
                glGetProgramInfoLog(shader, 1024, NULL, infoLog);
                std::cout << "ERROR::PROGRAM_LINKING_ERROR of type: " << type << "\n" << infoLog << "\n -- --------------------------------------------------- -- " << std::endl;
            }
        }
    }
};
#endif

Shader对象的构造必须要在加载完GLAD proc以后,否则报错。

纹理

简介

纹理(Texture)用于在不增加顶点数量的情况下添加物体的细节。它就像一层贴纸一样贴在几何体上。

纹理也可以用来存储数据。

纹理“贴”到几何体上的过程被称为映射(Map)。纹理坐标(Texture Coordinate)用于指定某个顶点该从纹理的哪个位置采样(Sample,采集Fragment颜色)。非顶点的几何体区域通过片段插值(Fragment Interpolation)采样。

纹理坐标以左下角为原点,右上角为(1, 1)点。

对于超出[0, 1]范围的纹理坐标,可以指定纹理环绕方式来处理。

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

img

使用glTexParameterx函数对特定坐标轴设置环绕方式。其中的‘x’代表数据类型,如i(int)、fv(float[])等。

//参数一指定纹理目标
//参数二指定参数选项以及坐标轴。S轴(x轴)T轴(y轴)
//参数三指定环绕方式
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);

选择GL_CLAMP_TO_BORDER时,使用float数组传入颜色数据。

float borderColor[] = { 1.0f, 1.0f, 0.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);

纹理过滤

纹理坐标可以是任意精度的浮点值,但纹理本身的分辨率却是有限的。因此,OpenGL需要知道,当指定一个纹理坐标时,该如何采样这个点上的像素。最为常见的是GL_NEARESTGL_LINEAR。前者选择最接近坐标的哪个像素,后者会基于坐标附近的像素,计算出插值。像素中心离坐标越近,它对最终颜色的贡献就越大。

二者的主要区别在于,前者会显得更“锯齿”,后者会显得更“模糊”。

当缩放几何体的时候,常常需要对纹理过滤进行设置,通常的做法是在纹理被缩小的时候使用邻近过滤,被放大时使用线性过滤。因为纹理缩小时,纹理像素也会变小,在视觉上,它的“分辨率似乎提高了”。因此,此时采用NEAREST过滤,纹理看起来就不会那么“锯齿化”。纹理放大时,像素看起来会更”明显“,所以使用LINEAR方法让纹理像素不那么明显,过渡更加平滑。

使用glTexParameterx函数为放大和缩小操作指定过滤方式。

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

多级渐远纹理

在我们当前的理解中,无论物体远近,其被映射的纹理的分辨率是不变的。对于非常远的物体,它们只会产生很少的Fragment。而OpenGL需要在如此高分辨率的纹理上拾取区区几个像素,是非常困难,并且效果不好的。为了解决这一问题,OpenGL引入了多级渐远纹理(Mipmap)。

img

Mipmap中,每个纹理的大小是前一个纹理的二分之一。当物体与相机的距离超过一定阈值后,OpenGL会采用小一级的纹理进行采样。

通过glGenerateMipmap函数创建Mipmap。

与纹理过滤选项类似,OpenGL提供了多种Mipmap匹配选项,用于缓解阈值附近Mipmap切换突兀的问题。

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

使用glTexParameteri设置Mipmap过滤方式。

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

一般只会对MIN_FILTER选项使用Mipmap。对MAG_FILTER使用Mipmap是不正确的。

加载与创建

头文件stb_image.h中的stbi_load函数接收图片路径作为输入,将图片的宽度、高度和颜色通道数输出到三个int变量上。

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

glGenBuffers类似,使用glGenTextures(int cnt, unsigned int* addr)生成纹理对象并获取句柄。

使用glBindTexture(GL_TEXTURE_2D, unsigned int texture)绑定纹理对象句柄与上下文目标。

使用glTexImage2d将图片信息复制到上下文目标中。

//上下文目标 | Mipmap级别 | 纹理存储格式 | 图片宽度 | 图片高度 | 总是为0 | 原图的格式 | 原图的数据类型 | 图像数据
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);

glBufferData类似,glTexImage2d的作用就是让当前绑定的纹理对象附加上真正的纹理图像。

glGenerateMipmap(GL_TEXTURE_2D)让OpenGL自动为我们生成、配置Mipmap,无需手动配置。

完成纹理和Mipmap生成后,应当释放图片内存:stbi_image_free(data);

完整的生成纹理过程如下:

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

纹理单元

sample2d类型uniform无需使用glUniform赋值。但glUniform可以设置sampler2d的位置值,这样我们就能给着色器设置多个纹理。一个纹理的位置值称为一个纹理单元(Texture Unit),其默认值为0。

使用glActiveTexture(GL_TEXTUREX)激活X号纹理单元, 随后使用glBindTexture为该位置值的纹理单元绑定纹理对象。

glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture1);
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, texture2);

使用自定义着色器类的setInt函数设置纹理单元位置值。

ourShader.setInt("texture2", 1);

在OpenGL中,纹理坐标的原点在左下角。而多数图像文件格式如PNG、JPEG等原点在左上角。所以直接加载这类图片会导致图片上下颠倒。

使用stbi_set_flip_vertically_on_load(true)反转图片y轴。

完整代码

#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <valarray>
#include "shader_s.h"
#include "stb_image.h"

using namespace std;

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    // 左上
};

unsigned int indices[] = {
    0, 1, 2,
    0, 2, 3
};

void frame_buffer_size_callback(GLFWwindow* window, int width, int height) {
    glViewport(0, 0, width, height);
}

int main() {
    // glfw初始化
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
    GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
    if (window == NULL) {
        cout << "Failed to create window" << endl;
        glfwTerminate();
        return -1;
    }
    glfwMakeContextCurrent(window);
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
        cout << "Failed to load proc" << endl;
        glfwTerminate();
        return -1;
    }
    glViewport(0, 0, 800, 600);
    glfwSetFramebufferSizeCallback(window, frame_buffer_size_callback);
    Shader shader("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\Shaders\\vertex.glsl", "C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\Shaders\\fragment.glsl");

    // 加载纹理
    unsigned char* data[2];
    int width[2], height[2], channels[2];
    stbi_set_flip_vertically_on_load(true);
    data[0] = stbi_load("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\container.jpg", &width[0], &height[0], &channels[0], 0);
    data[1] = stbi_load("C:\\Users\\msik\\CLionProjects\\LearnOpenGL\\awesomeface.png", &width[1], &height[1], &channels[1], 0);
    unsigned int textures[2];
    glGenTextures(2, textures);

    // 纹理1
    glBindTexture(GL_TEXTURE_2D, textures[0]);
    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);
    if (data[0]) {
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width[0], height[0], 0, GL_RGB, GL_UNSIGNED_BYTE, data[0]);
        glGenerateMipmap(GL_TEXTURE_2D);
    } else {
        cout << "Failed to load texture 1" << endl;
    }

    // 纹理2
    glBindTexture(GL_TEXTURE_2D, textures[1]);
    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);
    if (data[1]) {
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width[1], height[1], 0, GL_RGBA, GL_UNSIGNED_BYTE, data[1]);
        glGenerateMipmap(GL_TEXTURE_2D);
    } else {
        cout << "Failed to load texture 2" << endl;
    }

    // 释放图像数据
    stbi_image_free(data[0]);
    stbi_image_free(data[1]);

    // VAO
    unsigned int VAO;
    glGenVertexArrays(1, &VAO);
    glBindVertexArray(VAO);

    // VBO
    unsigned int VBO;
    glGenBuffers(1, &VBO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    // EBO
    unsigned int EBO;
    glGenBuffers(1, &EBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 顶点属性PTR
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float), (void*)(6 * sizeof(float)));
    glEnableVertexAttribArray(2);

    shader.use();
    shader.setInt("texture1", 0);
    shader.setInt("texture2", 1);

    while (!glfwWindowShouldClose(window)) {
        glClearColor(0.2f, 0.3f, 0.4f, 1.0f);
        glClear(GL_COLOR_BUFFER_BIT);

        // 绑定纹理
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, textures[0]);
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_2D, textures[1]);

        shader.use();
        glBindVertexArray(VAO);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    glfwTerminate();
    return 0;
}
#version 330 core
in vec3 col;
in vec2 uv;
out vec4 fragcolor;
uniform sampler2D texture1;
uniform sampler2D texture2;

void main(){
    fragcolor = mix(texture(texture1,uv),texture(texture2,uv),0.2f);
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aCol;
layout (location = 2) in vec2 aTex;

out vec3 col;
out vec2 uv;

void main(){
    gl_Position = vec4(aPos,1.0);
    col = aCol;
    uv = aTex;
}

变换

缩放

\[ \begin{bmatrix} \color{red}{S_1} & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{S_2} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}{S_3} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{S_1} \cdot x \\ \color{green}{S_2} \cdot y \\ \color{blue}{S_3} \cdot z \\ 1 \end{pmatrix} \]

三维空间的变换矩阵一般都是四维矩阵。w分量与具体变换无关。

位移

\[ \begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}{T_x} \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}{T_y} \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}{T_z} \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} x + \color{red}{T_x} \\ y + \color{green}{T_y} \\ z + \color{blue}{T_z} \\ 1 \end{pmatrix} \]

可以看到,因为有了w分量,所以才能实现位移。

w分量被称为齐次坐标(Homogeneous Coordinates)。齐次坐标为0的向量被称为方向向量,它无法被位移。

旋转

\[ \begin{bmatrix} \color{red}1 & \color{red}0 & \color{red}0 & \color{red}0 \\ \color{green}0 & \color{green}{\cos \theta} & - \color{green}{\sin \theta} & \color{green}0 \\ \color{blue}0 & \color{blue}{\sin \theta} & \color{blue}{\cos \theta} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} x \\ \color{green}{\cos \theta} \cdot y - \color{green}{\sin \theta} \cdot z \\ \color{blue}{\sin \theta} \cdot y + \color{blue}{\cos \theta} \cdot z \\ 1 \end{pmatrix} \]
\[ \begin{bmatrix} \color{red}{\cos \theta} & \color{red}0 & \color{red}{\sin \theta} & \color{red}0 \\ \color{green}0 & \color{green}1 & \color{green}0 & \color{green}0 \\ - \color{blue}{\sin \theta} & \color{blue}0 & \color{blue}{\cos \theta} & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{\cos \theta} \cdot x + \color{red}{\sin \theta} \cdot z \\ y \\ - \color{blue}{\sin \theta} \cdot x + \color{blue}{\cos \theta} \cdot z \\ 1 \end{pmatrix} \]
\[ \begin{bmatrix} \color{red}{\cos \theta} & - \color{red}{\sin \theta} & \color{red}0 & \color{red}0 \\ \color{green}{\sin \theta} & \color{green}{\cos \theta} & \color{green}0 & \color{green}0 \\ \color{blue}0 & \color{blue}0 & \color{blue}1 & \color{blue}0 \\ \color{purple}0 & \color{purple}0 & \color{purple}0 & \color{purple}1 \end{bmatrix} \cdot \begin{pmatrix} x \\ y \\ z \\ 1 \end{pmatrix} = \begin{pmatrix} \color{red}{\cos \theta} \cdot x - \color{red}{\sin \theta} \cdot y \\ \color{green}{\sin \theta} \cdot x + \color{green}{\cos \theta} \cdot y \\ z \\ 1 \end{pmatrix} \]

上面三个矩阵分别是以x、y、z轴旋转θ°的旋转矩阵。

这些矩阵复合可能会导致万向节死锁。沿任意旋转轴旋转θ°的旋转矩阵如下: $$ \begin{bmatrix} \cos \theta + \color{red}{R_x}^2(1 - \cos \theta) & \color{red}{R_x}\color{green}{R_y}(1 - \cos \theta) - \color{blue}{R_z} \sin \theta & \color{red}{R_x}\color{blue}{R_z}(1 - \cos \theta) + \color{green}{R_y} \sin \theta & 0 \ \color{green}{R_y}\color{red}{R_x} (1 - \cos \theta) + \color{blue}{R_z} \sin \theta & \cos \theta + \color{green}{R_y}^2(1 - \cos \theta) & \color{green}{R_y}\color{blue}{R_z}(1 - \cos \theta) - \color{red}{R_x} \sin \theta & 0 \ \color{blue}{R_z}\color{red}{R_x}(1 - \cos \theta) - \color{green}{R_y} \sin \theta & \color{blue}{R_z}\color{green}{R_y}(1 - \cos \theta) + \color{red}{R_x} \sin \theta & \cos \theta + \color{blue}{R_z}^2(1 - \cos \theta) & 0 \ 0 & 0 & 0 & 1 \end{bmatrix} $$

组合

当矩阵相乘时,在最右边的矩阵是第一个与向量相乘的,所以应该从右向左读矩阵乘法。

建议:在组合矩阵时,先进行缩放操作,然后是旋转,最后才是位移

GLM

GLM是在OpenGL中使用各类数学函数的头文件库。

#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>

glm::vec4 vec(x,y,z,w)用于创建一个名为vec的四维向量。

glm::mat4 trans = glm::mat4(1.0f)用于创建一个名为trans的四维矩阵,它是一个单位矩阵。

直接使用glm::mat4创建矩阵,会生成一个零矩阵。

当需要对一个向量进行变换操作时,用它乘以对应的变换矩阵即可。

平移

glm::translate(mat4, vec3)用于创建一个位移变换矩阵。mat4一般就是单位矩阵,vec3是位移向量。

缩放

glm::scale(mat4, vec3)用于创建缩放变换矩阵。mat4是单位矩阵,vec3是各轴缩放系数。

旋转

glm::rotate(mat4, radians, vec3)用于创建旋转矩阵。mat4是单位矩阵,radians是需要旋转的弧度,vec3是旋转轴。

radians是一个glm::radians类型变量,表示弧度。通过glm::radians(float angle)可把角度转换为弧度。

数据传递

定义mat4类型的uniform,使用如下代码传递:

unsigned int transformLoc = glGetUniformLocation(ourShader.ID, "transform");
//参数二:传递矩阵的个数
//参数三:是否转置。使用GLM(列主序)时无需转置
//参数四:把GLM矩阵转换为OpenGL可以读懂的类型
glUniformMatrix4fv(transformLoc, 1, GL_FALSE, glm::value_ptr(trans));

存在多个对象时,需要单独对每个对象进行变换。

shader.use();
glm::mat4 trans = glm::mat4(1.0f);
trans = glm::translate(trans,glm::vec3(-0.5f, 0.0f, 0.0f));
trans = glm::scale(trans,glm::vec3(sin(glfwGetTime()),sin(glfwGetTime()),0));
unsigned int transLoc = glGetUniformLocation(shader.ID,"trans");
glUniformMatrix4fv(transLoc,1,GL_FALSE,glm::value_ptr(trans));
glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);
trans = glm::mat4(1.0f);
trans = glm::translate(trans,glm::vec3(0.5f, -0.5f, 0.0f));
trans = glm::rotate(trans,100*glm::radians((float)glfwGetTime()),glm::vec3(0,0,1));
glUniformMatrix4fv(transLoc,1,GL_FALSE,glm::value_ptr(trans));
glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);

坐标

通常情况下,顶点坐标不一定在NDC范围以内。我们需要在顶点着色器中自行把这些坐标转化为NDC坐标。

这个坐标转化的过程类似于流水线。

在坐标转换的过程中,有三个变换矩阵非常重要:

  • 模型(Model)矩阵
  • 观察(View)矩阵
  • 投影(Projection)矩阵

坐标变换以下列顺序进行:

局部坐标 -> 世界坐标 -> 观察坐标 -> 裁剪坐标 -> 屏幕坐标

coordinate_systems

坐标变换概述:

  1. 局部坐标是对象相对于局部原点的坐标,也是物体起始的坐标。
  2. 下一步是将局部坐标变换为世界空间坐标,世界空间坐标是处于一个更大的空间范围的。这些坐标相对于世界的全局原点,它们会和其它物体一起相对于世界的原点进行摆放。
  3. 接下来我们将世界坐标变换为观察空间坐标,使得每个坐标都是从摄像机或者说观察者的角度进行观察的。
  4. 坐标到达观察空间之后,我们需要将其投影到裁剪坐标。裁剪坐标会被处理至-1.0到1.0的范围内,并判断哪些顶点将会出现在屏幕上。
  5. 最后,我们将裁剪坐标变换为屏幕坐标,我们将使用一个叫做视口变换(Viewport Transform)的过程。视口变换将位于-1.0到1.0范围的坐标变换到由glViewport函数所定义的坐标范围内。最后变换出来的坐标将会送到光栅器,将其转化为片段。

局部空间

单个物体所在的坐标空间。只在单个物体上有意义。

世界空间

每个物体摆放的不同位置。

使用模型矩阵将局部左边转换为世界坐标。

观察空间

又称摄像机空间或视觉空间,是从摄像机的视角所观察到的空间。

使用观察矩阵,将世界坐标转换为观察坐标。

裁剪空间

对于任何屏幕上不可见的坐标,都应当被剔除。剔除完以后,剩下的坐标就是屏幕上可见的片段。

使用投影矩阵将观察坐标变换到裁剪坐标。投影矩阵指定了一个范围的坐标,比如在每个维度上的-1000到1000。投影矩阵接着会将在这个指定的范围内的坐标变换为标准化设备坐标的范围(-1.0, 1.0)。所有在范围外的坐标不会被映射到在-1.0到1.0的范围之间,所以会被裁剪掉。

若一个图元一部分在裁剪范围内,一部分在范围外,在交界处将会生成新的顶点。

投影矩阵创建了一个观察箱(Viewing Box),这个观察箱被称为平截头体(Frustum,Unity里称为视锥)。

投影(Projection)是把特定范围内的坐标转换到NDC范围内的过程。

OpenGL Projection Matrix (songho.ca)

所有顶点被变换到裁剪空间后,一次透视除法将会执行。具体表现为:位置向量的x,y,z分量分别除以齐次w分量。这一步骤由顶点着色器自动执行。

随后,最终坐标会被映射到屏幕空间中,并被变换为Fragment。

投影矩阵分为正交(Orthographic)和透视(Perspective)投影矩阵。

正交投影矩阵所框定的范围类似于一个长方体。变换后,每个向量的w分量都不会改变。

使用glm::ortho(left, right, bottom, top, near, far)创建正射投影矩阵。

前两个参数为Frustum的左右坐标,第三、第四个参数为底部和顶部。第五第六个参数为近平面和远平面。

投射投影矩阵所框定的范围类似于一个四边台体。它会修改每个顶点坐标的w值,使得离观察者越远的顶点坐标,w分量就越大。这样,在执行透视除法时,越远的顶点坐标,其x、y、z值会被除的越多,就好像被缩小了一样,从而达成“近大远小”的效果。

使用glm::mat4 proj = glm::perspective(glm::radians(angle), (float)width / (float)height, near, far)创建投影矩阵。

参数一定义了视野(Field of view,FOV)的值,通常为45.0f.

参数二定义了宽高比。

参数三、四为近平面和远平面。

组合

V_{clip} = M_{projection} \cdot M_{view} \cdot M_{model} \cdot V_{local}

经过上面的变换后,就能得到应当被赋给gl_Position的坐标。随后,Vertex Shader对其进行透视除法和裁剪。

实践

首先创建模型矩阵。一种直观理解模型矩阵的方式是,Unity中的Transform组件。

在这里插入图片描述

模型矩阵对物体本身进行平移、旋转、缩放操作,对应Transform的三个属性。

要绘制世界坐标不同的物体的时候,只需要创建不同的模型矩阵即可。

glm::mat4 model;
//该模型矩阵让物体绕x轴旋转-55°
model = glm::rotate(model, glm::radians(-55.0f), glm::vec3(1.0f, 0.0f, 0.0f));

随后创建观察矩阵。当没有观察矩阵时,摄像机处于世界空间原点。在使用上面那个模型矩阵变换的情况下,物体同样处在世界原点。所以我们要往后退,以看到物体。而往后退等价于让物体往后退,所以有观察矩阵:

glm::mat4 view;
// 注意,我们将矩阵向我们要进行移动场景的反方向移动。
// OpenGL使用右手坐标系,z轴正方向指向屏幕外。
view = glm::translate(view, glm::vec3(0.0f, 0.0f, -3.0f));

最后创建投影矩阵。

glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);

然后修改Vertex Shader,并传入变换矩阵

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aCol;
layout (location = 2) in vec2 aTex;

out vec3 col;
out vec2 uv;
uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;

void main(){
    gl_Position = proj*view*model*vec4(aPos,1.0);
    col = aCol;
    uv = aTex;
}
        unsigned int modelLoc = glGetUniformLocation(shader.ID,"model");
        unsigned int viewLoc = glGetUniformLocation(shader.ID,"view");
        unsigned int projLoc = glGetUniformLocation(shader.ID,"proj");
        glUniformMatrix4fv(modelLoc,1,GL_FALSE,glm::value_ptr(model));
        glUniformMatrix4fv(viewLoc,1,GL_FALSE,glm::value_ptr(view));
        glUniformMatrix4fv(projLoc,1,GL_FALSE,glm::value_ptr(proj));
        glDrawElements(GL_TRIANGLES,6,GL_UNSIGNED_INT,0);

Z-Buffer

绘制立方体时,使用Z-Buffer解决覆盖问题。

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

深度测试默认关闭,使用glEnable(GL_DEPTH_TEST)开启。

同时,深度缓冲也需要在每帧清除。

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

绘制许多物体时,不妨使用for循环+改变model矩阵的方式。

完整代码

#include <iostream>
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include "shader_s.h"
#include "stb_image.h"
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>
using namespace std;

float offsetX=0;
float offsetY=0;
float offsetZ=0;

glm::vec3 cubePositions[] = {
    glm::vec3( 0.0f,  0.0f,  0.0f),
    glm::vec3( 2.0f,  5.0f, -15.0f),
    glm::vec3(-1.5f, -2.2f, -2.5f),
    glm::vec3(-3.8f, -2.0f, -12.3f),
    glm::vec3( 2.4f, -0.4f, -3.5f),
    glm::vec3(-1.7f,  3.0f, -7.5f),
    glm::vec3( 1.3f, -2.0f, -2.5f),
    glm::vec3( 1.5f,  2.0f, -2.5f),
    glm::vec3( 1.5f,  0.2f, -1.5f),
    glm::vec3(-1.3f,  1.0f, -1.5f)
  };

float vertices[] = {
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 0.0f,

    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 1.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,

    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,

    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,
     0.5f, -0.5f, -0.5f,  1.0f, 1.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
     0.5f, -0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f, -0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f, -0.5f, -0.5f,  0.0f, 1.0f,

    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f,
     0.5f,  0.5f, -0.5f,  1.0f, 1.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
     0.5f,  0.5f,  0.5f,  1.0f, 0.0f,
    -0.5f,  0.5f,  0.5f,  0.0f, 0.0f,
    -0.5f,  0.5f, -0.5f,  0.0f, 1.0f
};

void process_input(GLFWwindow* window) {
    if(glfwGetKey(window,GLFW_KEY_RIGHT)==GLFW_PRESS) {
        offsetX+=0.01f;
    }
    else if(glfwGetKey(window,GLFW_KEY_LEFT)==GLFW_PRESS) {
        offsetX-=0.01f;
    }
    if(glfwGetKey(window,GLFW_KEY_UP)==GLFW_PRESS) {
        offsetY+=0.01f;
    }
    else if(glfwGetKey(window,GLFW_KEY_DOWN)==GLFW_PRESS) {
        offsetY-=0.01f;
    }
}

void mouse_scroll_zoom(GLFWwindow* window, double xoffset, double yoffset) {
    if(yoffset>0) {
        offsetZ+=0.1f;
    }
    else if(yoffset<0) {
        offsetZ-=0.1f;
    }
}

void frame_buffer_size_callback(GLFWwindow* window, int width, int height) {
    glViewport(0,0,width,height);
}

GLFWwindow* sys_init(int width, int height) {
    //glfw初始化
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR,3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR,3);
    glfwWindowHint(GLFW_OPENGL_PROFILE,GLFW_OPENGL_CORE_PROFILE);
    GLFWwindow* window = glfwCreateWindow(800,600,"LearnOpenGL",NULL,NULL);
    if(window==NULL) {
        cout<<"Failed to create window"<<endl;
        glfwTerminate();
        return NULL;
    }
    glfwMakeContextCurrent(window);
    if(!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) {
        cout<<"Failed to load proc"<<endl;
        glfwTerminate();
        return NULL;
    }
    glViewport(0,0,800,600);
    glfwSetFramebufferSizeCallback(window,frame_buffer_size_callback);
    glfwSetScrollCallback(window,mouse_scroll_zoom);
    return window;
}

void quick_config_buffers(unsigned int GLTYPE, unsigned int* handle, void* data, unsigned int size) {
    glGenBuffers(1, handle);
    glBindBuffer(GLTYPE, *handle);
    glBufferData(GLTYPE, size, data, GL_STATIC_DRAW);
}

void quick_config_vao(unsigned int *VAO, unsigned int *VBO, unsigned int *EBO) {
    glGenVertexArrays(1, VAO);
    glBindVertexArray(*VAO);

    glBindBuffer(GL_ARRAY_BUFFER, *VBO);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, *EBO);

    // 激活顶点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);

    glBindVertexArray(0); // Unbind VAO
}

unsigned int quick_config_texture(const char* path,unsigned int IMAGETYPE) {
    unsigned int texture;
    stbi_set_flip_vertically_on_load(true);
    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_LINEAR);
    glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
    int width,height,channels;
    unsigned char* data = stbi_load(path, &width, &height,&channels,0);
    if(data) {
        glTexImage2D(GL_TEXTURE_2D,0,IMAGETYPE,width,height,0,IMAGETYPE,GL_UNSIGNED_BYTE,data);
        glGenerateMipmap(GL_TEXTURE_2D);
    }
    else {
        cout<<"Failed to load image"<<endl;
        return 0;
    }
    stbi_image_free(data);
    return texture;
}

int main() {
    //初始化系统与函数加载
    GLFWwindow* window = sys_init(800,600);
    if(window==NULL) return -1;
    //加载着色器
    Shader shader("Shaders/vertex.glsl","Shaders/fragment.glsl");
    //配置VBO、EBO、VAO
    unsigned int VBO, EBO, VAO;
    quick_config_buffers(GL_ARRAY_BUFFER, &VBO, vertices, sizeof(vertices));
    quick_config_vao(&VAO, &VBO, &EBO);
    //加载、配置纹理
    unsigned int textures[2];
    textures[0]=quick_config_texture("Imgs/container.jpg",GL_RGB);
    textures[1]=quick_config_texture("Imgs/awesomeface.png",GL_RGBA);
    shader.use();
    shader.setInt("texture1",0);
    shader.setInt("texture2",1);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D,textures[0]);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D,textures[1]);
    glEnable(GL_DEPTH_TEST);
    while(!glfwWindowShouldClose(window)) {
        process_input(window);
        glClearColor(0.2f,0.3f,0.4f,1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        shader.use();
        //变换
        glm::mat4 view = glm::mat4(1.0f);
        view = glm::translate(view,glm::vec3(0.0f+offsetX,0.0f+offsetY,-3.0f+offsetZ));
        glm::mat4 proj = glm::perspective(glm::radians(45.0f),800.0f/600.0f,0.1f,100.0f);
        shader.setMat4("view",view);
        shader.setMat4("proj",proj);
        glBindVertexArray(VAO);
        for(unsigned int i = 0; i < 10; i++)
        {
            glm::mat4 model = glm::mat4(1.0f);
            model = glm::translate(model, cubePositions[i]);
            float angle = 20.0f * i;
            model = glm::rotate(model, (float)glfwGetTime()*glm::radians(angle), glm::vec3(1.0f, 0.3f, 0.5f));
            shader.setMat4("model", model);
            glDrawArrays(GL_TRIANGLES, 0, 36);
        }
        //收尾操作
        glfwSwapBuffers(window);
        glfwPollEvents();
    }
    glDeleteTextures(2,textures);
    glfwTerminate();
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTex;

out vec2 uv;
uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;

void main(){
    gl_Position = proj*view*model*vec4(aPos,1.0);
    uv = aTex;
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTex;

out vec2 uv;
uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;

void main(){
    gl_Position = proj*view*model*vec4(aPos,1.0);
    uv = aTex;
}

摄像机

定义一个摄像机,需要获取其世界坐标、观察方向、指向其右侧与上方的方向向量。

  • 世界坐标:与观察矩阵所使用的变换相同。

  • glm::vec3 cameraPos = glm::vec3(0.0f,0.0f,3.0f)

  • 观察方向:借助矢量相减,获取摄像机位置与世界原点之间的方向向量。

  • glm::vec3 cameraDir = glm::normalize(cameraPos - vec3(0.0f,0.0f,0.0f))

  • 这样得到的实际上是观察方向的反方向。

  • 右轴:将上向量与观察方向叉乘。

  • c++ glm::vec3 up = glm::vec3(0.0f,1.0f,0.0f); glm::vec3 cameraRight = glm::normalize(glm::cross(up,cameraDir));

  • 上轴:将右轴与观察方向叉乘。

  • glm::vec3 cameraUp = glm::normalize(glm::cross(cameraRight,cameraDir))

上面的操作定义了一个额外的坐标空间。

这个操作被称为格拉姆-施密特正交化(Gram-Schmidt Process)

LookAt矩阵

坐标空间被定义后,便可以创建一个矩阵。把这个矩阵乘以任意向量,便可以把这个向量变换到我们定义的坐标空间。在摄像机空间中,这个矩阵被称为LookAt矩阵。

image-20240714112018261

其中,R为相机右轴,U为相机上轴,D为摄像机方向向量(相反,由原点指向相机),P为摄像机位置。

GLM提供了快速创建LookAt矩阵的方法。

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::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);
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);

对于任何仅表示方向的向量,都应该做正交化处理。

Deltatime

Deltatime变量存储了渲染上一帧需要的时间。将该变量引入移动速度的计算,就可以做到每帧的移动速度在各种设备上相对平衡。

float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;

视角旋转

欧拉角分为三种:

  • 俯仰角(Pitch):如何往上或往下看的角,绕x轴旋转。
  • 偏航角(Yaw):往左和往右看的程度,绕y轴旋转。
  • 滚转角(Roll):翻滚摄像机的程序,绕z轴旋转。

在Unity和其他引擎中,一般不关心滚转角。

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

理解上面的算法:

img

在x/z平面,可以计算水平距离和垂直距离。水平距离是cos(pitch),垂直距离(即最终的y分量)是sin(pitch)。

完成水平距离计算后,还要计算x分量和z分量。从上往下看x/z平面:

img

可以知道x分量是cos(yaw),z分量是sin(yaw)

对于鼠标输入,其水平移动影响Yaw角,竖直移动影响Pitch角。通过计算每一帧鼠标在垂直和水平方向与上一帧的插值,就可以得到具体的pitch和raw角。

glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED)让当前拥有焦点的窗口隐藏并捕捉鼠标。

捕捉(Capture)意味着,无论鼠标如何移动,都不会离开窗口范围。

定义鼠标移动回调函数:void mouse_callback(GLFWwindow* window, double xpos, double ypos),并将其注册到glfwSetCursorPosCallback(window, mouse_callback)

void mouse_callback(GLFWwindow* window, double xpos, double ypos)
{
    //判断鼠标是否第一次进入窗口。如果不设置,会发生鼠标第一次进入窗口时突然跳一下的情况。
    if(firstMouse)
    {
        lastX = xpos;
        lastY = ypos;
        firstMouse = false;
    }

    //计算鼠标x、y方向的增量
    float xoffset = xpos - lastX;
    //注意:这里要反过来加,否则是空战游戏的操作模式。
    float yoffset = lastY - ypos; 
    lastX = xpos;
    lastY = ypos;

    //灵敏度配置
    float sensitivity = 0.05;
    xoffset *= sensitivity;
    yoffset *= sensitivity;

    yaw   += xoffset;
    pitch += yoffset;

    //限制pitch,防止视角偏转。空战类游戏可能不需要限制
    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);
}

视角缩放

缩放作用于FOV,FOV是投影矩阵范畴的概念,所以缩放是改变的投影矩阵。

void scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
{
  if(fov >= 1.0f && fov <= 45.0f)
    fov -= yoffset;
  if(fov <= 1.0f)
    fov = 1.0f;
  if(fov >= 45.0f)
    fov = 45.0f;
}

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

glfwSetScrollCallback(window, scroll_callback);

手动计算

glm::mat4 GetViewMatrix()
{
    // 重新计算摄像机的 Right 和 Up 向量
    Right = glm::normalize(glm::cross(Front, WorldUp));
    Up = glm::normalize(glm::cross(Right, Front));

    // 计算LookAt矩阵
    glm::mat4 rotation(1.0f);
    rotation[0][0] = Right.x;
    rotation[1][0] = Right.y;
    rotation[2][0] = Right.z;
    rotation[0][1] = Up.x;
    rotation[1][1] = Up.y;
    rotation[2][1] = Up.z;
    //负的是因为,实际上变的是物体而不是摄像机
    rotation[0][2] = -Front.x;
    rotation[1][2] = -Front.y;
    rotation[2][2] = -Front.z;

    glm::mat4 translation(1.0f);
    translation[3][0] = -Position.x;
    translation[3][1] = -Position.y;
    translation[3][2] = -Position.z;

    return rotation * translation;
}

知识点

  • 相机空间的施密特正交化:相机位置、方向向量(目标指向相机)、右轴、上轴
  • LookAt矩阵与观察矩阵:view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp)
  • 此处cameraFront为相机指向目标的向量
  • Deltatime控制速度
direction.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
direction.y = sin(glm::radians(pitch));
direction.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
  • 鼠标输入
  • glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED)
  • glfwSetCursorPosCallback(window, mouse_callback)
  • pitch角的限制,注意弧度转换
  • 初次鼠标进入时的判断
  • 滚轮、FOV与投影矩阵

复习

  • OpenGL: 一个定义了函数布局和输出的图形API的正式规范。

除OpenGL外,还有DirectX 11,DirectX 12,Metal,Vulkan等图形API。在教程中,我们使用的OpenGL版本是3.3。

我们使用GLFW管理窗口相关的API。

  • GLAD: 一个扩展加载库,用来为我们加载并设定所有OpenGL函数指针,从而让我们能够使用所有(现代)OpenGL函数。

在OpenGL中,所有函数都是运行时动态确定的,因此,IDE没办法给我们提供编译时语法检查和代码补全。为了解决这一问题,我们引入GLAD库,让上述功能成为可能。

gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)用来加载所有OpenGL函数指针。

  • 视口(Viewport): 我们需要渲染的窗口。

视口与窗口是不同的概念。视口指的是渲染范围,是最终裁剪变换处理的区域。使用glViewport(x,y,width,height)初始化并配置视口。

  • 图形管线(Graphics Pipeline): 一个顶点在呈现为像素之前经过的全部过程。

基本的图形管线可以定义为:顶点数据->顶点着色器->几何着色器->形状装配->光栅化->片段着色器->测试与混合->输出

  • 着色器(Shader): 一个运行在显卡上的小型程序。很多阶段的图形管道都可以使用自定义的着色器来代替原有的功能。
  • 标准化设备坐标(Normalized Device Coordinates, NDC): 顶点在通过在剪裁坐标系中剪裁与透视除法后最终呈现在的坐标系。所有位置在NDC下-1.0到1.0的顶点将不会被丢弃并且可见。

经过顶点着色器处理的坐标必定处于NDC范围内。

  • 顶点缓冲对象(Vertex Buffer Object): 一个调用显存并存储所有顶点数据供显卡使用的缓冲对象。

glDrawArray函数直接使用VBO内的数据进行绘制。

  • 顶点数组对象(Vertex Array Object): 存储缓冲区和顶点属性状态

在不使用VAO时,我们每次想绘制一个物体,都需要手动绑定对应的VBO,设置顶点属性指针。

VAO可以把这些操作保存,并生成一个VAO对象。之后每次想要绘制这个物体,绑定对应的VAO即可。

  • 元素缓冲对象(Element Buffer Object,EBO),也叫索引缓冲对象(Index Buffer Object,IBO): 一个存储元素索引供索引化绘制使用的缓冲对象。

通常,输入的顶点数据不会包含重复的顶点。但对于一些图元来说,它们是共用顶点的。为了简化输入顶点数据,我们使用EBO记录绘制对象所使用的顶点序号。

  • Uniform: 一个特殊类型的GLSL变量。它是全局的(在一个着色器程序中每一个着色器都能够访问uniform变量),并且只需要被设定一次。

使用glUniformnx(Location, value)向某着色器的某个uniform传递值。

location使用glGetUniformLocation(shaderProgram, nameofuniform)获取。

n代表参数个数,x代表数据类型,可以是fv(float数组)、4i(ivec4)等。

向Uniform传值之前,对应的着色器程序必须被use。

  • 纹理(Texture): 一种包裹着物体的特殊类型图像,给物体精细的视觉效果。

glGenTexture(cnt, addr)用于创建纹理对象。

glBindTexture(GL_TEXTURE_2D, textureObj)用于绑定对象到纹理上下文。

glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_X,GL_X)用于设置纹理参数。参数有GL_TEXTURE_WARP和GL_TEXTURE_MIN_FILTER等。

glTexImage2D(GL_TEXTURE_2D,0,IMAGETYPE,width,height,0,IMAGETYPE,GL_UNSIGNED_BYTE,data)用于将纹理数据载入上下文。

glGenerateMipmap(GL_TEXTURE_2D)用于生成当前上下文存储纹理的多级渐远纹理。

glActiveTexture(GL_TEXTUREX)用于激活X号纹理单元。类似于,让GL_TEXTURE_2D目标换到X号槽。

Active操作以后要Bind。

  • 纹理环绕(Texture Wrapping): 定义了一种当纹理顶点超出范围(0, 1)时指定OpenGL如何采样纹理的模式。

  • 纹理过滤(Texture Filtering): 定义了一种当有多种纹素选择时指定OpenGL如何采样纹理的模式。这通常在纹理被放大情况下发生。

  • 多级渐远纹理(Mipmaps): 被存储的材质的一些缩小版本,根据距观察者的距离会使用材质的合适大小。
  • stb_image.h: 图像加载库。

stbi_load(path, &width, &height,&channels,0)

  • 纹理单元(Texture Units): 通过绑定纹理到不同纹理单元从而允许多个纹理在同一对象上渲染。
  • 向量(Vector): 一个定义了在空间中方向和/或位置的数学实体。
  • 矩阵(Matrix): 一个矩形阵列的数学表达式。
  • GLM: 一个为OpenGL打造的数学库。
  • 局部空间(Local Space): 一个物体的初始空间。所有的坐标都是相对于物体的原点的。

局部空间类似于Unity的Transform属性的参数。

  • 世界空间(World Space): 所有的坐标都相对于全局原点。

  • 观察空间(View Space): 所有的坐标都是从摄像机的视角观察的。

  • 裁剪空间(Clip Space): 所有的坐标都是从摄像机视角观察的,但是该空间应用了投影。这个空间应该是一个顶点坐标最终的空间,作为顶点着色器的输出。OpenGL负责处理剩下的事情(裁剪/透视除法)。
  • 屏幕空间(Screen Space): 所有的坐标都由屏幕视角来观察。坐标的范围是从0到屏幕的宽/高。
  • LookAt矩阵: 一种特殊类型的观察矩阵,它创建了一个坐标系,其中所有坐标都根据从一个位置正在观察目标的用户旋转或者平移。
  • 欧拉角(Euler Angles): 被定义为偏航角(Yaw),俯仰角(Pitch),和滚转角(Roll)从而允许我们通过这三个值构造任何3D方向。

CMake与ImGui

设置 CMake 最低版本和项目名称

cmake_minimum_required(VERSION 3.28)
project(LearnOpenGL)
  • cmake_minimum_required(VERSION 3.28):这行代码指定了我们希望使用的最低 CMake 版本是 3.28。
  • project(LearnOpenGL):这行代码将我们的项目命名为 "LearnOpenGL"。

设置 C++ 标准

为了确保代码能够使用特定的 C++ 标准,我们需要明确指定它:

set(CMAKE_CXX_STANDARD 17)
  • set(CMAKE_CXX_STANDARD 17):这行代码将 C++ 标准设置为 C++17。这样可以确保我们的代码能够使用 C++17 的特性,同时也能确保编译器正确处理这些特性。

手动设置 GLFW 路径

由于我们使用的是本地安装的 GLFW 库,因此需要手动设置其包含目录和库目录:

set(GLFW_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include/GLFW")
set(GLFW_LIB_DIR "${CMAKE_SOURCE_DIR}/libs")
set(GLFW_LIBRARY "${CMAKE_SOURCE_DIR}/libs/glfw3.dll")
  • set(GLFW_INCLUDE_DIR "C:/Users/msik/CLionProjects/LearnOpenGL/include/GLFW"):设置 GLFW 的包含目录路径,使编译器能够找到 GLFW 的头文件。
  • set(GLFW_LIB_DIR "C:/Users/msik/CLionProjects/LearnOpenGL/libs"):设置 GLFW 的库目录路径。
  • set(GLFW_LIBRARY "C:/Users/msik/CLionProjects/LearnOpenGL/libs/glfw3.dll"):设置 GLFW 的库文件路径。

${CMAKE_SOURCE_DIR}是内置宏,指CMakeList.txt所在的目录。

添加 IMGUI 库

IMGUI 是一个常用的图形用户界面库。我们需要将其添加到我们的项目中:

add_library(IMGUI SHARED
        ./imgui/imgui.cpp
        ./imgui/imgui_impl_glfw.cpp
        ./imgui/imgui_impl_opengl3.cpp
        ./imgui/imgui_draw.cpp
        ./imgui/imgui_tables.cpp
        ./imgui/imgui_widgets.cpp
        ./imgui/imgui_demo.cpp
        ./imgui/imgui_stdlib.cpp
)
  • add_library(IMGUI SHARED ...):这行代码定义了一个共享库,名为 IMGUI,并包含了多个源文件。共享库可以在多个程序之间共享,提高了代码复用性。

接下来设置 IMGUI 库的包含目录和链接库:

target_include_directories(IMGUI PRIVATE ${GLFW_INCLUDE_DIR})
target_link_libraries(IMGUI PRIVATE ${GLFW_LIBRARY})
  • target_include_directories(IMGUI PRIVATE ${GLFW_INCLUDE_DIR}):将 GLFW 的包含目录添加到 IMGUI 库的私有包含目录中,确保 IMGUI 库可以访问 GLFW 的头文件。
  • target_link_libraries(IMGUI PRIVATE ${GLFW_LIBRARY}):将 GLFW 库文件链接到 IMGUI 库中,使 IMGUI 库能够使用 GLFW 的功能。

添加可执行文件

接下来,我们为项目添加一个可执行文件:

add_executable(LearnOpenGL
        Archive/main.cpp
        glad.c
        include/shader_s.h
        GLMTest.cpp
        stbitmp.cpp
        include/camera.h
)
  • add_executable(LearnOpenGL ...):这行代码定义了一个可执行文件,名为 LearnOpenGL,并指定了其源文件列表。可执行文件是最终生成的程序,可以运行。

包含路径

我们需要指定包含目录,以便编译器能够找到所有头文件:

include_directories(${GLFW_INCLUDE_DIR} "${CMAKE_SOURCE_DIR}/include")
  • include_directories(...):这行代码将 GLFW 的包含目录和项目的包含目录添加到编译器的搜索路径中,使编译器能够找到这些头文件。

链接库

最后,我们需要将所有必要的库链接到可执行文件中:

target_link_libraries(LearnOpenGL PRIVATE ${GLFW_LIBRARY} IMGUI)
  • target_link_libraries(LearnOpenGL PRIVATE ${GLFW_LIBRARY} IMGUI):这行代码将 GLFW 库和 IMGUI 库链接到 LearnOpenGL 可执行文件中,使其能够使用这些库的功能。

与OpenGL工程集成

  1. 添加头文件
#include "imgui.h"
#include "imgui_impl_glfw.h"
#include "imgui_impl_opengl3.h"
  1. 初始化
IMGUI_CHECKVERSION(); //检查版本
ImGui::CreateContext(); //创建上下文
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; //激活键盘支持

ImGui_ImplGlfw_InitForOpenGL(window, true); //在GLFW窗口上进行初始化
ImGui_ImplOpenGL3_Init();
  1. 每次渲染初始化
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
if(ImGui::Begin("窗口名")){
    //窗口控件逻辑放在这
    //..
    ImGui::End();
}
  1. 每次渲染结束
ImGui::Render();
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
  1. 程序终止
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();

控件

文本

变体函数 作用描述 示例代码
ImGui::Text 显示简单文本 ImGui::Text("This is some useful text.");
ImGui::TextColored 显示带颜色的文本 ImVec4 color = ImVec4(1.0f, 0.0f, 0.0f, 1.0f);
ImGui::TextColored(color, "This is red text.");
ImGui::TextDisabled 显示灰色文本,表示禁用状态 ImGui::TextDisabled("This is disabled text.");
ImGui::TextWrapped 显示自动换行的文本 ImGui::TextWrapped("This is some long text that will automatically wrap.");
ImGui::TextUnformatted 显示不进行格式化的文本 const char* text = "This is unformatted text.";
ImGui::TextUnformatted(text);
ImGui::Text (格式化字符串) 使用格式化字符串显示文本 int value = 42;
ImGui::Text("The answer is %d", value);

按钮

控件函数 作用描述 示例代码
ImGui::Button 创建一个按钮 if (ImGui::Button("Click Me")) { /* 按钮被点击时执行的代码 */ }
ImGui::SmallButton 创建一个小按钮 if (ImGui::SmallButton("Click Me")) { /* 小按钮被点击时执行的代码 */ }
ImGui::InvisibleButton 创建一个不可见的按钮 if (ImGui::InvisibleButton("Click Me", ImVec2(100, 20))) { /* 按钮被点击时执行的代码 */ }

复选框

控件函数 作用描述 示例代码
ImGui::Checkbox 创建一个复选框 static bool checked = false;
ImGui::Checkbox("Check Me", &checked);
ImGui::CheckboxFlags 创建一个带有标志的复选框 static int flags = 0;
ImGui::CheckboxFlags("Flag 1", &flags, 1);

输入框

控件函数 作用描述 示例代码
ImGui::InputText 创建一个文本输入框 static char text[128] = "";
ImGui::InputText("Input Text", text, IM_ARRAYSIZE(text));
ImGui::InputTextMultiline 创建一个多行文本输入框 static char text[128] = "";
ImGui::InputTextMultiline("Input Text", text, IM_ARRAYSIZE(text));
ImGui::InputInt 创建一个整数输入框 static int value = 0;
ImGui::InputInt("Input Int", &value);
ImGui::InputFloat 创建一个浮点数输入框 static float value = 0.0f;
ImGui::InputFloat("Input Float", &value);

滑块

控件函数 作用描述 示例代码
ImGui::SliderFloat 创建一个浮点数滑块 static float value = 0.0f;
ImGui::SliderFloat("Float Slider", &value, 0.0f, 1.0f);
ImGui::SliderInt 创建一个整数滑块 static int value = 0;
ImGui::SliderInt("Int Slider", &value, 0, 100);
ImGui::VSliderFloat 创建一个垂直浮点数滑块 static float value = 0.0f;
ImGui::VSliderFloat("VFloat Slider", ImVec2(20,100), &value, 0.0f, 1.0f);
ImGui::VSliderInt 创建一个垂直整数滑块 static int value = 0;
ImGui::VSliderInt("VInt Slider", ImVec2(20,100), &value, 0, 100);

下拉框

控件函数 作用描述 示例代码
ImGui::Combo 创建一个下拉框 static int item = 0;
const char* items[] = { "Item 1", "Item 2", "Item 3" };
ImGui::Combo("Combo", &item, items, IM_ARRAYSIZE(items));

光照

颜色

颜色可以数字化的由红色(Red)、绿色(Green)和蓝色(Blue)三个分量组成,它们通常被缩写为RGB。

glm中,使用glm::vec3 COLOR_NAME(float x, float y, float z)定义颜色变量。

现实中,我们看到物体的颜色实际上是被物体反射(即无法被物体吸收)的颜色与光源颜色的叠加。因此,要计算最终我们看到物体的颜色,可以用光源颜色*物体颜色的方式得到最终的颜色项链。

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

严谨的物体颜色定义应为:物体从一个光源反射各个颜色分量的大小。考虑如下光源和物体:

glm::vec3 lightColor(0.0f, 1.0f, 0.0f);
glm::vec3 toyColor(1.0f, 0.5f, 0.31f);

可以得出,物体在反射光源颜色时,R和B通道压根就没有颜色给它反射。而G通道可以反射一半,最终得到颜色(0.0f, 0.5f, 0.0f),即深绿色。

当两个物体使用不同材质时,需要创建两个不同的Shader对象。

注意:

绘制应用不同Shader的对象时,需要use()

同时,也需要设置新Shader的View和Projection矩阵。

基础光照

冯氏光照模型(Phong Lighting Model)是一种简单的光照模型。它分为三个部分:

  • 环境光(Ambient):在任何情况下都给予物体的颜色。
  • 漫反射光(Diffuse):模拟光源对物体的方向性影响。物体的某部分越正对光源,就越亮。
  • 高光(Specular):有光泽表面上出现的亮点,其颜色更加接近于光照颜色本身。

环境光

全局光照(Global Illumination,GI)是考虑到间接光照的算法。环境光是一种极其简化的全局光照方式。其实现方式如下:

    float ambientStrength = 0.1; //环境光强度,其值通常很小
    vec3 ambient = ambientStrength * lightColor; //环境光色 
    vec3 result = ambient * objectColor; //叠加物体本身颜色
    FragColor = vec4(result, 1.0);

漫反射光

法向量(Normal Vector)是垂直于片段表面的向量。法向量一般存储在顶点数据里,作为一个顶点属性。

两个单位向量的夹角越小,它们点乘的结果越倾向于1。借助这一点,实现漫反射效果,即:越正对光源,颜色越亮。

image-20240715205247231

这个算法需要获取两个数据:光照方向和法向量。前者通过使用uniform向着色器传递lightPos获得,后者通过顶点属性配置获得。

任何不涉及距离,只涉及方向的向量与计算都应当归一化

    vec3 normal = normalize(norm);
    vec3 lightDir = normalize(lightPos-fragWorldPos);
    float diffuseStrength = max(dot(normal,lightDir),0.0); //防止得到负数
    vec3 diffuse = diffuseStrength*lightColor;
    vec3 ambient = ambientStrength*lightColor;
    FragColor = vec4((ambient+diffuse)*objectColor,1.0f); //相加而非相乘

传入的法向量是基于局部坐标的。法向量不能简单地通过乘以模型矩阵变换,因为这些变换会破坏发现的垂直性质。

img

法线矩阵(Normal Matrix)是专门将法向量变换到世界坐标的矩阵。它是模型矩阵左上叫3x3部分的逆矩阵的法线矩阵。因此,可以使用Normal = mat3(transpose(inverse(model))) * aNormal来计算世界坐标下的法线。这一操作应当在cpp程序中进行,然后通过uniform传递到着色器,因为矩阵求逆对着色器来说是非常耗时的。

高光

高光取决于LightDir、Norm和ViewDir。

image-20240715205521385

如图,入射光和反射光与法线的夹角都是α,ViewDir和反射光的夹角是θ。高光最强的地方就是反射光所在的方向,因此,θ越小,高光越强。因此,同样可以通过点乘来得到高光系数。

//高光计算
vec3 viewDir = normalize(viewPos-fragWorldPos); //viewDir由uniform传入,指相机的世界坐标
vec3 reflectDir = reflect(-lightDir,normal); //reflect函数的参数一是由光源指向物体的方向向量
vec3 specular = pow(max(dot(reflectDir,viewDir),0.0),shineness)*lightColor*specularStrength;//shineness是反光度属性,越大则高光影响范围越大,强度越强

冯氏光照模型的三个部分是相加的。

在顶点着色器中完成的冯氏光照模型角Gouraud着色。它的效果不好,因为其颜色由插值决定。

代码

世界空间下

    float ambientStrength = 0.1f;
    float specularStrength = 0.5f;
    int shineness = 32;
    while(!glfwWindowShouldClose(window)) {
        process_keyboard_input(window);
        glClearColor(0.2f,0.3f,0.4f,1.0f);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        shader.use();
        shader.setVec3("viewPos",camera.Position.x,camera.Position.y,camera.Position.z);
        //绘制imgui交互窗口
        imgui_frame_init();
        if(ImGui::Begin("window")){
            ImGui::SliderFloat("AmbientStrength",&ambientStrength,0.0f,1.0f);
            shader.setFloat("ambientStrength",ambientStrength);
            ImGui::SliderFloat("SpecularStrength",&specularStrength,0.0f,1.0f);
            shader.setFloat("specularStrength",specularStrength);
            ImGui::SliderInt("Shineness",&shineness,2,256);
            shader.setInt("shineness",shineness);
            ImGui::SliderFloat3("LightPos",glm::value_ptr(lightPos),-3.0f,3.0f);
            ImGui::Checkbox("canViewRotate(K to switch)",&canMove);
            ImGui::End();
        }
        float currFrame=glfwGetTime();
        deltaTime = currFrame-lastFrame;
        lastFrame = currFrame;
        //变换
        glm::mat4 view = camera.GetViewMatrix();
        glm::mat4 proj = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 100.0f);
        shader.setMat4("view",view);
        shader.setMat4("proj",proj);
        //绘制物体
        glBindVertexArray(VAO);
        glm::mat4 model = glm::mat4(1.0f);
        shader.setMat4("model", model);
        shader.setVec3("lightPos",lightPos.x,lightPos.y,lightPos.z);
        glDrawArrays(GL_TRIANGLES, 0, 36);
       //绘制光源
        glBindVertexArray(lightVAO);
        lightShader.use();
        lightShader.setMat4("view",view);
        lightShader.setMat4("proj",proj);
        model = glm::translate(model,lightPos);
        model = glm::scale(model,glm::vec3(0.5f));
        lightShader.setMat4("model", model);
        glDrawArrays(GL_TRIANGLES, 0, 36);
        imgui_end_draw();
        //收尾操作
        glfwSwapBuffers(window);
        glfwPollEvents();
    }
#version 330 core
out vec4 FragColor;
in vec3 norm;
in vec3 fragWorldPos;

uniform vec3 objectColor;
uniform vec3 lightColor;
uniform float ambientStrength;
uniform float specularStrength;
uniform int shineness;
uniform vec3 lightPos;
uniform vec3 viewPos;

void main() {
    //漫反射光计算
    vec3 normal = normalize(norm);
    vec3 lightDir = normalize(lightPos-fragWorldPos);
    float diffuseStrength = max(dot(normal,lightDir),0.0);
    vec3 diffuse = diffuseStrength*lightColor;
    //环境光计算
    vec3 ambient = ambientStrength*lightColor;
    //高光计算
    vec3 viewDir = normalize(viewPos-fragWorldPos);
    vec3 reflectDir = reflect(-lightDir,normal);
    vec3 specular = pow(max(dot(reflectDir,viewDir),0.0),shineness)*lightColor*specularStrength;
    FragColor = vec4((ambient+diffuse+specular)*objectColor,1.0f);
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNorm;

uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;
out vec3 norm;
out vec3 fragWorldPos;

void main()
{
    gl_Position = proj * view * model * vec4(aPos, 1.0);
    fragWorldPos = (model*vec4(aPos,1.0f)).xyz;
    norm = aNorm;
}

观察空间下

#version 330 core
out vec4 FragColor;
in vec3 norm;
in vec3 fragViewPos;
in vec3 lightViewPos;

uniform vec3 objectColor;
uniform vec3 lightColor;
uniform float ambientStrength;
uniform float specularStrength;
uniform int shineness;

void main() {
    //漫反射光计算
    vec3 normal = normalize(norm);
    vec3 lightDir = normalize(lightViewPos-fragViewPos);
    float diffuseStrength = max(dot(normal,lightDir),0.0);
    vec3 diffuse = diffuseStrength*lightColor;
    //环境光计算
    vec3 ambient = ambientStrength*lightColor;
    //高光计算
    vec3 viewDir = normalize(-fragViewPos);
    vec3 reflectDir = reflect(-lightDir,normal);
    vec3 specular = pow(max(dot(reflectDir,viewDir),0.0),shineness)*lightColor*specularStrength;
    FragColor = vec4((ambient+diffuse+specular)*objectColor,1.0f);
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNorm;

uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;
uniform vec3 lightPos;
out vec3 norm;
out vec3 fragViewPos;
out vec3 lightViewPos;

void main()
{
    gl_Position = proj * view * model * vec4(aPos, 1.0);
    fragViewPos = (view*model*vec4(aPos,1.0f)).xyz;
    lightViewPos = (view*vec4(lightPos,1.0f)).xyz;
    norm =  mat3(transpose(inverse(view * model)))*aNorm; //关键在于使用法线矩阵变换法线位置
}

材质

在通常的着色器编写中,并不是直接使用 objectColor 计算表面颜色的,而是使用材质(Material)。材质定义了物体表面的反射特性,包含环境光、漫反射率和镜面反射率等属性。

与 C 语言类似,OpenGL 也可以定义结构体来组织数据。以下示例展示了如何在 GLSL 中定义一个 Material 结构体,并将其用作 uniform 变量:

#version 330 core
struct Material {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    float shininess;
}; 
uniform Material material;

环境光

ambient 材质向量定义了在环境光照下,这个表面反射的颜色。环境光通常是模拟全局光照的,它在所有方向上均匀地影响物体的每个部分。ambient 通常设置为表面的基础颜色,以确保即使在阴影中物体也能被轻微看到。

漫反射

diffuse 材质向量定义了表面的漫反射颜色。漫反射模拟了光在粗糙表面上的扩散反射,使光在多个方向上散射。漫反射颜色设置为期望的物体颜色,因为它直接影响物体在被光照射时的可见颜色。

镜面反射

specular 材质向量设置了表面的镜面反射颜色。它决定了光在表面上的镜面高光颜色。这种反射通常用于模拟光滑表面的光泽度或闪亮效果。高光的颜色可以是白色的(表示强光反射),也可以是其他颜色,具体取决于表面材料的特性。

镜面反射度

shininess 参数影响镜面高光的散射程度或半径。较高的 shininess 值会使高光更加集中和尖锐,模拟光滑或抛光的表面;较低的 shininess 值会使高光更加扩散和柔和,模拟粗糙的表面。

材质属性的设置需要丰富的实践。

光源

通常来说,物体对于环境光、漫反射光和高光的反射力度是不同的。材质描述了物体在反射这三类光时的颜色属性,而反射力度是另一种截然不同的属性。

struct Light {
    vec3 position;
    vec3 ambient; //环境光影响系数
    vec3 diffuse; //漫反射率
    vec3 specular; //镜面反射率
};
uniform Light light;

一般,环境光的反射力度较小,在0.1f左右。漫反射可以在0.5f-0.7f左右,而高光一般都为1.0f。

我该如何理解漫反射、镜面反射率?

与漫反射颜色不同,漫反射率是指物体对漫反射颜色中R、G、B分量的反射程度。

假设漫反射率为(0.2f, 0.3f, 0.4f),那么,漫反射颜色中,有20%的红色能被漫反射到观察者视角中,30%的绿色以及40%的红色同理。

镜面反射率也是如此。镜面反射率通常被设置为(1.0f, 1.0f, 1.0f),因为镜面反射一般直接反映出光源的颜色。

光照贴图

之前,我们对材质三个光照分类的控制,是使用传入uniform来实现的。但实际上,我们经常会遇到一个物体的不同部分是不同材质的情况。为了处理这种情况,我们引入光照贴图(Map)的概念,对材质的不同区域设置不同的光照分量强度。

贴图,类似于纹理,也是一种覆盖物体的图像。它允许着色器逐片段索引其中的颜色值。

和纹理一样,在着色器内使用sampler2D类型定义采样器,并使用texture(sampler2D tex, vec2 uv)函数采样。

漫反射贴图

漫反射贴图(Diffuse Map)可以看作是传统光照模型(如Phong、Blinn-Phong)中的Base-Color。它表现了物体本身的颜色。

struct Material {
    sampler2D diffuse;
    vec3      specular;
    float     shininess;
}; 
...
in vec2 TexCoords;
...
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoords));
vec3 ambient = vec3(texture(material.diffuse,TexCoords))*lightColor*light.ambient;

这里移除了结构体内的vec3 ambient属性,因为几乎在所有情况下,环境光颜色都等于漫反射颜色,所以环境光用漫反射贴图进行采样。

高光贴图

高光贴图用于控制高光分量。

vec3 specular = specularRatio * lightColor * vec3(texture(material.specular, TexCoords)) * light.specular

投光物

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

平行光

平行光,又称定向光(Directional Light),投射的所有光线都来自于同一方向,与光源的位置无关。

太阳光被视为一种平行光。

对于定向光,其结构体中只需包含一个方向向量和三个光照分量即可。

struct Light {
    vec3 direction;
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};
#version 330 core

struct Material {
    sampler2D diffuse;
    sampler2D specular;
    float shininess;
};

struct Light {
    vec3 direction;
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    vec3 color;
    float strength;
};

uniform Light light;
uniform Material material;
uniform vec3 viewPos;
uniform float time;

in vec3 norm;
in vec3 fragPos;
in vec2 TexCoords;
out vec4 FragColor;

void main() {
    // 计算光源方向
    vec3 lightDir = normalize(-light.direction);

    // 计算环境光
    vec3 ambient = vec3(texture(material.diffuse, TexCoords)) * light.ambient;

    // 计算漫反射光
    vec3 normal = normalize(norm);
    float diffuseStrength = max(dot(normal, lightDir), 0.0);
    vec3 diffuse = vec3(texture(material.diffuse, TexCoords)) * diffuseStrength * light.diffuse;

    // 计算高光
    vec3 viewDir = normalize(viewPos - fragPos);
    vec3 reflectDir = reflect(-lightDir, normal);
    float specularStrength = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    vec3 specular = specularStrength * vec3(texture(material.specular, TexCoords)) * light.specular;

    // 合并所有光照效果
    vec3 result = ambient + diffuse + specular;

    // 输出最终颜色
    FragColor = vec4(result*light.color*light.strength, 1.0f);
}

点光源

与平行光不同,点光源(Point Light)的光线会随距离衰减(Attenuation)。

在现实世界中,灯在近处通常会非常亮,但随着距离的增加光源的亮度一开始会下降非常快,但在远处时剩余的光强度就会下降的非常缓慢了。为了模拟这一过程,我们使用下列公式: $$ \begin{equation} F_{att} = \frac{1.0}{K_c + K_l * d + K_q * d^2} \end{equation} $$ 其中,\(F_{att}\)是衰减率,用于乘以光照强度。

\(K_{c}\)是常数项,通常为1.0,用于保证分母大于1,使得衰减率始终随距离增大而减小。

\(K_{l}\)是一次项系数,以线性方式减少强度。

\(K_{q}\)是二次项系数,当距离较大时,二次项的影响会更加显著。。

经过该公式计算的衰减率乘以光强,最终得到的亮度如下:

img

三个K值的具体值设置需要实践经验。一次项系数越小,光源覆盖的距离越大,二次项系数的变化趋势与一次项系数相同,但它比一次项系数小更多。

距离 常数项 一次项 二次项
7 1.0 0.7 1.8
13 1.0 0.35 0.44
20 1.0 0.22 0.20
32 1.0 0.14 0.07
50 1.0 0.09 0.032
65 1.0 0.07 0.017
100 1.0 0.045 0.0075
160 1.0 0.027 0.0028
200 1.0 0.022 0.0019
325 1.0 0.014 0.0007
600 1.0 0.007 0.0002
3250 1.0 0.0014 0.000007

具体实现衰减同样需要修改着色器中的Light结构体,并把计算得到的衰减值乘以三个光照分量。

struct Light {
    //光源位置
    vec3 position;
    //材质光照分量
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    //灯光自身属性
    vec3 color;
    float strength;
    //点光源衰减参数
    float constant;
    float linear;
    float quadratic;
};

    void main(){
            float distance = length(fragPos-light.position);
    float attenuation = 1.0/(light.constant+distance*light.linear+distance*distance*light.quadratic);
    ambient  *= attenuation;
    diffuse   *= attenuation;
    specular *= attenuation;
    // 合并所有光照效果
    vec3 result = ambient + diffuse + specular;
    // 输出最终颜色
    FragColor = vec4(result*light.color*light.strength, 1.0f);
    }

聚光

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

聚光可以用一个世界坐标、一个方向和一个切光角(Cufoff Angle)确定。切光角指定了光锥体的半径。

image-20240717122930242

如图所示,θ代表图元和聚光方向的夹角,ϕ代表切光角。

具体计算过程为:首先判断θ的cos值,若大于cosϕ,则说明片段位于光锥体内,执行光照计算(与点光源相同)。若小于,则直接输出环境光色。

使用smoothstep(float t1, float t2, float x)函数来生成平滑边缘。

当x小于t1时,函数返回0;x大于t2时,函数返回1;x位于[t1, t2]时,进行平滑插值。

struct Light {
    vec3 direction;
    vec3 position;
    float cutoff;
    float outer;
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    vec3 color;
    float strength;
};
...
void main() {
    //计算聚光区域
    vec3 light_frag_dir = normalize(fragPos-light.position);
    float theta = dot(light_frag_dir,normalize(light.direction));
    float spotRange = smoothstep(light.outer,light.cutoff,theta);
    vec3 lightDir = -light.direction;
    // 计算环境光
    vec3 ambient = vec3(texture(material.diffuse, TexCoords)) * light.ambient;
    // 计算漫反射光
    vec3 normal = normalize(norm);
    float diffuseStrength = max(dot(normal, lightDir), 0.0);
    vec3 diffuse = vec3(texture(material.diffuse, TexCoords)) * diffuseStrength * light.diffuse;
    // 计算高光
    vec3 viewDir = normalize(viewPos - fragPos);
    vec3 reflectDir = reflect(-lightDir, normal);
    float specularStrength = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
    vec3 specular = specularStrength * vec3(texture(material.specular, TexCoords)) * light.specular;
    // 合并所有光照效果
    vec3 result = ambient + diffuse*spotRange + specular*spotRange;
    // 输出最终颜色
    FragColor = vec4(result*light.color*light.strength, 1.0f);
}

多光源

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

为了实现多光源效果,我们需要将每个光源对各光照分量的贡献进行累加。

对于数组类型的uniform,使用“pointLights[0].position”来访问。

多个光源对片段的影响就是简单的相加

uniform DirectionalLight dirLight;
uniform SpotLight spotLight;
uniform PointLight pointLights[NR_POINT_LIGHTS];

vec3 CalcDirLight(DirectionalLight light, vec3 normal, vec3 viewDir){
    vec3 lightDir = normalize(-light.direction);
    vec3 ambient = light.ambient * texture(material.diffuse,TexCoords).rgb;
    vec3 diffuse = max(dot(lightDir,normal),0.0f)*light.diffuse*texture(material.diffuse,TexCoords).rgb;
    vec3 reflectDir = reflect(-lightDir,normal);
    vec3 specular = pow(max(dot(viewDir,reflectDir),0.0f),material.shininess)*light.specular*texture(material.specular,TexCoords).rgb;
    return (ambient+diffuse+specular)*light.color*light.strength;
}

vec3 CalcPointLight(PointLight light, vec3 normal, vec3 viewDir, vec3 fragPos){
    float distance = length(light.position - fragPos);
    float attenuation = 1/(light.constant+light.linear*distance+light.quadratic*distance*distance);
    vec3 lightDir = normalize(light.position - fragPos);
    vec3 ambient = light.ambient * texture(material.diffuse,TexCoords).rgb;
    vec3 diffuse = max(dot(lightDir,normal),0.0f)*light.diffuse*texture(material.diffuse,TexCoords).rgb;
    vec3 reflectDir = normalize(reflect(-lightDir,normal));
    vec3 specular = pow(max(dot(viewDir,reflectDir),0.0f),material.shininess)*light.specular*texture(material.specular,TexCoords).rgb;
    return (ambient+diffuse+specular)*attenuation*light.color*light.strength;
}

vec3 CalcSpotLight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir){
    float distance = length(light.position - fragPos);
    float attenuation = 1/(light.constant+light.linear*distance+light.quadratic*distance*distance);

    vec3 lightFragVec = normalize(fragPos-light.position);
    float theta = dot(lightFragVec,normalize(light.direction));
    float spotRange = smoothstep(light.outer,light.cutoff,theta);
    vec3 lightDir = -light.direction;
    vec3 ambient = light.ambient * texture(material.diffuse,TexCoords).rgb;
    vec3 diffuse = max(dot(lightDir,normal),0.0f)*light.diffuse*texture(material.diffuse,TexCoords).rgb;
    vec3 reflectDir = normalize(reflect(-lightDir,normal));
    vec3 specular = pow(max(dot(viewDir,reflectDir),0.0f),material.shininess)*light.specular*texture(material.specular,TexCoords).rgb;
    return ((ambient+diffuse)*spotRange+specular)*attenuation*light.color*light.strength;
}

void main(){
    vec3 normal = normalize(norm);
    vec3 viewDir = normalize(viewPos-fragPos);
    vec3 result = vec3(0.0);
    for(int i = 0; i < NR_POINT_LIGHTS; i++){
        result += CalcPointLight(pointLights[i], normal,viewDir,fragPos);
    }
    result += CalcDirLight(dirLight,normal,viewDir);
    if(spotLightSwitch){
        result += CalcSpotLight(spotLight, normal, fragPos,viewDir);
    }
    FragColor = vec4(result,1.0f);
}

模型

Assimp

3D建模工具如Blender、3DS Max在导出模型文件时,会自动生成所有的顶点坐标、顶点法线和纹理坐标。

.obj格式只包含了模型数据和材质信息(颜色、贴图等),而collada格式则非常丰富,甚至包含了场景、摄像机信息等。

Assimp是一个开源的模型导入库,支持数十种不同的3D模型格式。

使用Assimp导入模型时,通常会把模型加载入一个场景(Scene)对象,它包含了导入的模型/场景内的所有数据。Assimp会把场景载入为一系列的节点,每个节点包含了场景对象中存储数据的索引。

img

Scene节点包含了对场景根节点的引用。根节点包含的子节点会有一系列指向场景节点中mMeshes数据中存储的网格数据的索引。Scene节点的mMeshes数组存储了真正的Mesh对象。

我们可以这么理解:真正的Mesh数据存在Scene节点里,Scene节点本身在层级面板中不可见;

根节点和子节点就像是层级面板中的父对象和一个个子对象,它们不存储数据,只存储索引。

Mesh对象包含了渲染需要的所有数据,如顶点位置、法向量、纹理坐标、面(Face)和材质(含贴图等)。

面指的是物体的渲染图元(Primitive),如三角形、点、线等。面包含了组成图元的顶点和索引。

借助Assimp加载模型的步骤如下:

  • 加载物体到Scene对象中
  • 遍历所有节点,获取每个节点对应的Mesh对象
  • 处理每个Mesh对象以获取渲染所需的数据

完成上述步骤后,我们得到的是一系列Mesh数据,被包含在一个Model对象中。

一个Model由若干个Mesh组成。一个Mesh是一个单独的形状,是OpenGL里绘制物体的最小单位。

引入工程

Github Release页面下载最新版本的Assimp源码:assimp/assimp: The official Open-Asset-Importer-Library Repository. Loads 40+ 3D-file-formats into one unified and clean data structure. (github.com)

把根目录在Clion中打开,然后构建项目,把构建好的dll放到libs文件夹下,随后修改CMakeList.txt:

cmake_minimum_required(VERSION 3.28)
project(LearnOpenGL)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -O0 -g")
set(CMAKE_BUILD_TYPE Debug)
# 手动设置 GLFW 路径
set(GLFW_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include/GLFW")
set(GLFW_LIB_DIR "${CMAKE_SOURCE_DIR}/libs")
set(GLFW_LIBRARY "${CMAKE_SOURCE_DIR}/libs/glfw3.dll")
set(ASSIMP_LIBRARY "${CMAKE_SOURCE_DIR}/libs/libassimp-5d.dll")
set(ASSIMP_INCLUDE_DIR "${CMAKE_SOURCE_DIR}/include/assimp")
# 添加 IMGUI 库
add_library(IMGUI SHARED
        ./imgui/imgui.cpp
        ./imgui/imgui_impl_glfw.cpp
        ./imgui/imgui_impl_opengl3.cpp
        ./imgui/imgui_draw.cpp
        ./imgui/imgui_tables.cpp
        ./imgui/imgui_widgets.cpp
        ./imgui/imgui_demo.cpp
        ./imgui/imgui_stdlib.cpp
)

target_link_libraries(IMGUI PRIVATE ${GLFW_LIBRARY})

# 添加可执行文件
add_executable(LearnOpenGL
        Archive/main.cpp
        glad.c
        include/shader_s.h
        GLMTest.cpp
        stbitmp.cpp
        include/camera.h
        include/mesh.h
        include/model.h
        include/shader.h
)

# 包含路径
include_directories(${GLFW_INCLUDE_DIR} "${CMAKE_SOURCE_DIR}/include" ${ASSIMP_INCLUDE_DIR})

# 链接库
target_link_libraries(LearnOpenGL PRIVATE ${GLFW_LIBRARY} IMGUI ${ASSIMP_LIBRARY})

Assimp数据结构

struct aiNode{
    aiNode **mChildren; //子节点数组
    unsigned int *mMeshes; //网格数据的索引数组
    aiMetadata* mMetaData; //元数据数组
    aiString mName; //节点名
    unsigned int mNumChildren; //子节点数量
    unsigned int mNumMeshes; //网格数量
    aiNode *mParent; //父节点
    aiMatrix4x4 mTransformation; //变换矩阵
}
struct aiScene{
    aiAnimation** Animations; //可通过HasAnimations成员函数判断是否为0
    aiCamera** mCameras; //同上
    unsigned int mFlags;
    aiLight** mLights;
    aiMaterial** mMaterials;
    aiMesh** mMeshes;
    aiMetadata* mMetaData;
    aiString mName;
    unsigned int mNumAnimations;
    unsigned int mNumCameras;
    unsigned int mNumLights;
    unsigned int mNumMaterials;
    unsigned int mNumMeshes;
    unsigned int mNumTextures;
    aiNode* mRootNode;
    aiTexture **mTextures;
}
struct aiMesh{
    aiAnimMesh** mAnimMeshes;
    aiVector3D* mBitangents;
    aiBone** mBones;
    aiColor4D* mColors[AI_MAX_NUMBER_OF_COLOR_SETS];
    aiFaces* mFaces;
    unsigned int mMaterialIndex;
    unsigned int mMethod;
    aiString mName;
    aiVector3D* mNormals;
    unsigned int mNumAnimMeshes;
    unsigned int mNumBones;
    unsigned int mNumFaces;
    unsigned int mNumUVComponents[AI_MAX_NUMBER_OF_TEXTURECOORDS];
    unsigned int mNumVertices;
    unsigned int mPrimitiveTypes;
    aiVector3D* mTangents;
    aiVector3D* mTextureCoords[AI_MAX_NUMBER_OF_TEXTURECOORDS];
    aiString mTextureCoordsNames[AI_MAX_NUMBER_OF_TEXTURECOORDS];
    aiVector3D* mVertices;
}

网格

网格(Mesh)代表的是单个可绘制实体,它包含了顶点数据、索引和纹理数据。

我们来逐个考虑需要的属性:

  • 顶点由一个位置向量定义,为了让顶点表现出正常的光照效果,我们需要定义顶点的法向量。同时,纹理坐标使得纹理能正确映射到形状表面。这三个属性恰好是我们之前在VBO中存储的数据。
  • 纹理对象生成后由一个无符号整数句柄引用。同时,为了知道这个纹理是漫反射贴图、高光贴图还是别的什么,我们需要一个字符串(或枚举)来定义它的类型。

由此定义结构体:

struct Vertex {
    glm::vec3 Position;
    glm::vec3 Normal;
    glm::vec2 TexCoords;
}
struct Texture{
    unsigned int id;
    string type;
    aiString path; //用于存储纹理路径
}

由于索引只是无符号整数的几何,所以无需单独定义结构体。

定义完网格对象中存储的内容后,就可以着手构建网格类了。

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); //使用特定着色器绘制形状,同时可设置uniform
    private:
        vector<Texture> textures_loaded; //用于存放已加载的纹理,防止重复加载
        //渲染数据
        unsigned int VAO,VBO,EBO;
        //函数
        void setupMesh(); //用于初始化缓冲
}

初始化

Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures){
    this.vertices = vertices;
    this.indices = indices;
    this.textures = textures;
    setupMesh();
}

void setupMesh(){
    glGenVertexArrays(1,&VAO);
    glGenBufferArrays(1,&VBO);
    glGenBufferArrays(1,&EBO);
    glBindVertexArray(VAO);
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    //参数二以字节为单位
    //vertices是Vector对象,非地址
    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);
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
    glEnableVertexAttribArray(1);
    glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));
    glEnableVertexAttribArray(2);
    glBindVertexArray(0);
}

注意:offsetof(struct attrib)关键字可以用于求取属性attrib在结构体struct内的偏移值(字节单位)。但由于结构体内的属性在内存上是连续的,所以实际上也可以用x * sizeof(float)来代替。

渲染

Draw函数用于设置uniform,指定绘制操作等。

我们定义:着色器中采样器的名称应当被定义为texture_diffuseNtexture_specularN,其中N∈[1, MAX_TEXTURE_COUNT]。

    void Draw(Shader &shader) {
        unsigned int diffuseNr = 1;
        unsigned int specularNr = 1;
        for(unsigned int i=0;i<(int)textures.size();i++) {
            glActiveTexture(GL_TEXTURE0+i);
            string name = textures[i].type;
            string number;
            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);
        }
        glBindVertexArray(VAO);
        glDrawElements(GL_TRIANGLES,(sizeof(unsigned int))*(int)indices.size(),GL_UNSIGNED_INT,0);
        glBindVertexArray(0);
        glActiveTexture(GL_TEXTURE0);
    }

导入模型

前面我们了解到,一个Model对象包含多个Mesh对象。据此,我们定义Model类:

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);
}

对于Draw函数,遍历所有的网格,并调用它们的Draw函数。

void Draw(Shader shader){
    for(unsigned int i=0;i<(int)meshes.size();i++){
        meshes[i].Draw(shader);
    }
}

对于使用Assimp相关代码的源文件,需要包含Importer.hppscene.h以及postprocess.h头文件。

Importer类用于快速地加载模型文件:

void loadModel(string path){
    Assimp::Importer importer;
    //参数一为文件路径,参数二为后处理选项。此处意味:将所有图元转换为三角形|翻转纹理坐标以适应OpenGL设置
    //除此以外,还有:
    //aiProcess_GenNormals - 生成法向量
    //aiProcess_SplitLargeMeshes - 分割大网格,防止超过顶点渲染限制
    //aiProcess_OptimizeMeshes - 合并小网格,减少Drawcall
    const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);
    //检查场景和根节点是否为null.
    //mFlags与特定宏求与,得到场景是否完全加载。这么做的目的是:位操作性能好
    if(!scene||scene->mFlags&AI_SCENE_FLAGS_INCOMPLETE||!scene->mRootNode){
        //导入期的GetErrorString()函数可得到错误信息
        cout<<"ERROR::ASSIMP::"<<import.GetErrorString()<<endl;
        return;
    }
    //剔除文件本身的名称,得到目录路径
    directory = path.substr(0,path.find_last_of('/')); //find_last_of:查找string最后出现的某字符的索引
    //由根节点开始,可以遍历到所有节点。所以首先处理根节点
    //processNode函数为递归函数
    processNode(scene->mRootNode,scene);
}
void processNode(aiNode *node, const aiScene* scene){
    //mNumMeshes指当前节点存储的网格数据数量
    for(unsigned int i=0;i<node->mNumMeshes;i++){
        //记住,节点只存放网格索引,场景中存放的才是真正的网格数据
        aiMesh *mesh = scene->mMeshes[node->mMeshes[i]];
        meshes.push_back(processMesh(mesh,scene));
    }
    for(unsigned int i=0;i<node->mNumChildren;i++){
        //递归处理子节点
        processNode(node->mChildren[i],scene);
    }
}

之所以费这么多心思遍历子节点获取网格,而不是直接遍历aiScene的Mesh数组,是因为:

无论是在游戏引擎里还是在3D建模软件中,都存在类似层级面板的东西。在这里,网格之间有严格的父子关系,而节点之间的关系就体现了这一点。

如果单纯遍历Mesh数组,那网格之间的父子关系就被丢弃了。

processMesh函数用于把aiMesh对象转换为我们自己的Mesh类。实现这一步很简单,只需要访问aiMesh的所有属性,并把它们赋值给Mesh类的属性即可。

Mesh processMesh(aiMesh* mesh, const aiScene* scene){
    vector<Vertex> vertices;
    vector<Texture> textures;
    vector<unsigned int> indices;
    //处理顶点
    for(unsigned int i=0;i<mesh->mNumVertices;i++){
        Vertex vertex;
        glm::vec3 tmpVec;
        tmpVec.x = mesh->mVertices[i].x;
        tmpVec.y = mesh->mVertices[i].y;
        tmpVec.z = mesh->mVertices[i].z;
        vertex.Position = tmpVec;
        tmpVec.x = mesh->mNormals[i].x;
        tmpVec.y = mesh->mNormals[i].y;
        tmpVec.z = mesh->mNormals[i].z;
        vertex.Normal = tmpVec;
        glm::vec2 uv;
        //aiMesh结构体的mTexCoords可以被看作是二维数组。它的第一维是纹理的序号(Assimp允许同一个顶点上包含八个纹理的uv),第二维才是表示uv的二维向量。
        if(mesh->mTexCoords[0]){
            uv.x = mesh->mTexCoords[0][i].x;
            uv.y = mesh->mTexCoords[0][i].y;
            vertex.TexCoords = uv;
        }
        else{
            vertex.TexCoords = glm::vec2(0.0f,0.0f);
        }
        vertices.push_back(vertex);
    }
    //处理索引
    //每个网格包含了若干面,每个面包含了绘制这个面的顶点索引。
    for(unsigned int i=0;i<mesh->mNumFaces;i++){
        aiFace face = mesh->mFaces[i];
        for(unsigned int j=0;j<face.mNumIndices;j++){
            indices.push_back(face.mIndices[j]);
        }
    }
    //处理材质
    //一个网格只能使用一个材质,如果网格没有材质,mMaterialIndex为负数
    //和节点-网格的关系一样,网格本身只存储材质索引,场景对象才存储真正的aiMaterial
    if(mesh->mMaterialIndex>=0){
        aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
        vector<Texture> diffuseMaps = loadMaterialTextures(material,aiTextureType_DIFFUSE,"texture_diffuse");
        //其实这里用for循环也行
        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());
    }
}

到这里,我们Mesh类的属性就都填充完毕了。接下来,我们要结合stbi_image库来加载材质中的纹理。

vector<Texture> loadMaterialTextures(aiMaterial* mat, aiTextureType type, string typeName){
    vector<Texture> textures;
    for(unsigned int i=0;i<mat->GetTextureCount(type);i++){
        aiString str;
        //这里获取到的str是纹理的文件名,而非路径
        mat->GetTexture(type,i,&str);
        bool skip = false;
        for(unsigned int j = 0; j < this->textures_loaded.size(); j++)
        {
            //aiString.data()也可以用于获取const char*
            //这里匹配了当前纹理与textures_loaded数组中的内容。若发现匹配的,则直接跳过加载
            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;
            //aiString可以用C_Str()函数转化为const char*
            //这里的directory是模型所在的目录
            texture.id = TextureFromFile(str.C_Str(),this->directory); 
            texture.type = typeName;
            texture.path = str;
            textures.push_back(texture);
        }
    }
    return textures;
}

unsigned int TextureFromFile(const char* path, const string &directory){
    string filename = string(path);
    filename = directory + '/' + filename;
    unsigned int id;
    glGenTextures(1,&id);
    int width, height, channels;
    unsigned char* data = stbi_load(filename.c_str(), &width,&height,&channels,0);
    if(data){
        GLenum format;
        if(channels==1){
            format = GL_RED;
        }
        else if(channels==3){
            format = GL_RGB;
        }
        else if(channels==4){
            format = GL_RGBA;
        }
        glBindTexture(GL_TEXTURE_2D,id);
        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 id;
}

记得修改Shader:

struct Material{
    sampler2D texture_diffuse1;
    sampler2D texture_specular1;
    float shininess;
};

高级OpenGL

深度测试

深度缓冲(Depth Buffer,or Z-Buffer)用于放置被阻挡的面被渲染到其他面的前面。

在每个Fragment中都存储有Depth Buffer信息,它由程序自动创建,一般情况下是24位的float。

当深度测试(Depth Test)被启用时,OpenGL会把Fragment的深度值和深度缓冲内容进行对比,这个过程被称为深度测试。测试通过时,深度缓冲就会更新为这个片段的深度值,否则这个片段会被剔除。

深度缓冲运行在模板测试后,作用于屏幕空间

gl_FragCoord是GLSL内建变量,它是一个vec3,x和y分量代表了片段的屏幕坐标(左下角为原点),z分量为片段的深度值。

提前深度测试(Early Depth Testing, Early-Z)允许深度测试在Fragment着色器之前运行。只要判断该片段在其他物体之后,便会将他提前剔除。

使用Early-Z的条件是,Fragment Shader里不能有写入深度值的操作。

使用glEnable(GL_DEPTH_TEST)开启深度测试。

开启深度测试后,在每个渲染迭代开始之前还应当使用glClear(GL_DEPTH_BUFFER_BIT)清除深度缓冲。

使用glDepthMask(GL_FALSE)禁用深度缓冲写入,深度缓冲将不会更新,作为只读属性。

深度测试函数

glDepthFunc函数用于控制OpenGL什么时候通过、丢弃片段,以及什么时候更新深度缓冲。它接收一个比较符。

函数 描述
GL_ALWAYS 永远通过深度测试
GL_NEVER 永远不通过深度测试
GL_LESS 在片段深度值小于缓冲的深度值时通过测试
GL_EQUAL 在片段深度值等于缓冲区的深度值时通过测试
GL_LEQUAL 在片段深度值小于等于缓冲区的深度值时通过测试
GL_GREATER 在片段深度值大于缓冲区的深度值时通过测试
GL_NOTEQUAL 在片段深度值不等于缓冲区的深度值时通过测试
GL_GEQUAL 在片段深度值大于等于缓冲区的深度值时通过测试

默认比较符为GL_LESS

我该如何理解深度缓冲值的更新?

GL_LESS为例:当视口中还未渲染任何东西时,深度缓冲值是无穷大。

当我们渲染了两个方块时,这两个方块的z值必然比无穷大要小,它们通过了深度测试,两个方块占据的片段位置的深度缓冲被更新为新的深度值。

当渲染位于两个方块底下的地板时,对于没被方块遮挡的部分的片段深度值,它们的深度缓冲值依然是无穷大,所以它们能通过深度测试,正常显示。而被方块遮挡的部分,很明显深度值要大于方块的深度值,所以未通过测试,被剔除。

深度值精度

只要一个float表示的是深度,那么它的范围必定是[0.0, 1.0]。深度缓冲存储的float的范围都是如此。

但我们知道,片段的z值可不是这样。为了把z值转换为深度值,我们使用方程:$\(\begin{equation} F_{depth} = \frac{z - near}{far - near} \end{equation}\)$

其中,nearfar是平截头体的近平面z值和远平面z值。

上面的公式被称为线性深度缓冲(Linear Depth Buffer)。这种方法实际上不是很好,因为对于透视投影的观察者来说,极远处物体的z轴变化是很难观察到的,而近处物体z轴很微小的变化都会很明显。为了体现这点,我们引入非线性深度缓冲方程:\(\begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation}\)

对于这个方程,z值和最终深度的变化如下图:

img

可以看到,深度值的很大一部分都是由很小的z值决定的。

非线性深度值转换方程被嵌入到了投影矩阵中,在观察空间->裁剪空间的转换过程中被应用。

这意味着,我们使用gl_FragCoord.z得到的值就是非线性深度值。

$z_{view} = \frac{2 \cdot far \cdot near}{(far + near)-(far - near) \cdot z_{ndc} } $

上述方程用于把非线性深度值转换为线性。它是使用投影矩阵推导得出的。其中Zndc是NDC坐标下的z值,由原深度值*2-1变换得到。在shader中可以这么写:

float z = depth * 2.0 - 1.0;
float linearDepth = (2.0 * near * far) / (far + near - z * (far - near));

深度冲突

深度冲突(Z-fight)指两个片段的深度值非常接近,以至于深度缓冲没有足够的精度来决定该显示哪个片段的情况。深度冲突发生时,可以看到锯齿状的贴图闪烁。

一般我们采用下列方法防止深度冲突:

  • 不要把两个物体摆的太近。
  • 提高近平面的值,从而让整个平截头体的深度缓冲精度提高。代价是近处物体可能会被剔除。

可以这么理解:让非线性转换中,z-深度曲线曲率最大的部分向后移动,从而让z值稍大的部分也能以高精度进行深度测试。

  • 使用高精度深度缓冲。

模板测试

模板测试(Stencil Test)紧接着Fragment Shader处理完一个片段后执行。

模板缓冲类似于一个遮罩。当片元的模板缓冲值为1时,通过测试,否则剔除。

与深度缓冲类似,模板缓冲通过glEnable(GL_STENCIL_TEST)开启,每次渲染循环通过glClear(GL_STENCIL_BUFFER_BIT)清除上帧缓存,通过glStencilMask设置位掩码。

glStencilFunc(GLenum func, GLint ref, GLuint mask)用于告诉程序如何进行模板测试。

func:设置模板缓冲函数。可用的选项有:GL_NEVER、GL_LESS、GL_LEQUAL、GL_GREATER、GL_GEQUAL、GL_EQUAL、GL_NOTEQUAL和GL_ALWAYS。

ref:参考值,之后的模板缓冲将与此值比较。

mask:掩码,一般都是0xFF。

glStencilFunc(GL_EQUAL, 1, 0xFF)为例,这个语句代表:只要模板值等于1,就通过模板测试。

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 按位翻转当前的模板缓冲值

描边

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

我该如何理解这段代码?

我们使用模板测试来实现描边的效果。

这里用的方法是:在原物体的位置,复制一个物体,将其略微放大一些。这个物体应用的Shader应当是不受光照影响的纯色Shader。

描边就是要让原物体好好地显示出来,而原物体所占据片元以外的地方,允许大物体显示。

首先,我们定义模板测试失败和成功后的结果:glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); 当模板测试通过时,替换片元模板值为ref值。否则保留原本的模板值。

我们需要先绘制不需要描边的物体。需要注意,绘制这些物体时,应当禁用模板缓冲(可以直接glDisable,也可以glStencilMask(0x00))。

当绘制到原物体时,开启模板缓冲,让原物体占据片元区域的模板值变为1。

glStencilFunc(GL_ALWAYS,1,0XFF)执行后,对于新渲染的片段,模板测试始终通过。

glStencilMask(0xFF)开启模板值写入。

然后绘制原物体。

原物体绘制完毕后,片元的模板值更新完毕。改变模板测试规则:glStencilFunc(GL_NOTEQUAL,1,0XFF),使得片段所处位置的模板值只有不为1时,才通过。

然后绘制大物体。因为原物体片元区域的模板值都是1,所以大物体的模板测试不会通过,这些片元不会被渲染。这就达到了描边的效果。

为什么要禁用深度测试呢?因为描边区域通常不可被障碍遮挡。如果有这个需求,也可以不禁用。

混合

png图片是四通道的,第四通道的值代表透明度(alpha)。

通过在Fragment Shader中对采样的alpha值进行判断并剔除(discard),可以实现“透明的地方不渲染”的效果:

    vec4 col = texture(tex,TexCoord);
    if(col.a<0.1f){
        discard;
    }
    FragColor = col;

采用这种方法实现透明显示时,需要把纹理环绕方式设置为GL_CLAMP_TO_EDGE,否则当实际渲染物体的大小超过纹理大小时,底部uv会重复到顶部,导致物体的重复渲染。

采用discard方案的缺点是,无法实现半透明物体的渲染。同时,使用discard以后Early-Z将失效。

为实现半透明物体的渲染,我们引入Blend技术。

glEnable(GL_BLEND)

Blend借助alpha值实现”物体本身“和”后方物体“颜色的混合。让我们举一个具体例子:

img

我们把绿色半透明Quad放在红色不透明Quad前面。绿色Quad的alpha值是0.6,那么当二者叠加时,叠加区域的最终颜色中,绿色Quad对颜色的贡献值就是60%,红色则是(1-60%)=40%。最终颜色就是:

\(\begin{equation}\bar{C}_{result} = \begin{pmatrix} \color{red}{0.0} \\ \color{green}{1.0} \\ \color{blue}{0.0} \\ \color{purple}{0.6} \end{pmatrix} * \color{green}{0.6} + \begin{pmatrix} \color{red}{1.0} \\ \color{green}{0.0} \\ \color{blue}{0.0} \\ \color{purple}{1.0} \end{pmatrix} * \color{red}{(1 - 0.6)} \end{equation}\)

其中,0.6被称为源因子值,(1-0.6)被称为目标因子值

glBlendFunc(GLenum sfactor, GLenum dfactor)用于设置源因子和目标因子值。

选项
GL_ZERO 因子等于0
GL_ONE 因子等于1
GL_SRC_COLOR 因子等于源颜色向量C¯source
GL_ONE_MINUS_SRC_COLOR 因子等于1−C¯source1
GL_DST_COLOR 因子等于目标颜色向量C¯destination
GL_ONE_MINUS_DST_COLOR 因子等于1−C¯destination
GL_SRC_ALPHA 因子等于C¯source的alpha分量
GL_ONE_MINUS_SRC_ALPHA 因子等于1−C¯source的alpha分量
GL_DST_ALPHA 因子等于C¯destination的alpha分量
GL_ONE_MINUS_DST_ALPHA 因子等于1− C¯destination的alpha分量
GL_CONSTANT_COLOR 因子等于常数颜色向量C¯constant
GL_ONE_MINUS_CONSTANT_COLOR 因子等于1−C¯constant1
GL_CONSTANT_ALPHA 因子等于C¯constant的alpha分量
GL_ONE_MINUS_CONSTANT_ALPHA 因子等于1− C¯constant的alpha分量

默认混合方式为glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)。C_constant使用glBlendColor函数设置。

glBlendFuncSeparate可以分别对RGBA通道使用不同的混合方式。

glBlendEquation(GLEnum mode)可以改变混合的计算方式:

  • GL_FUNC_ADD:默认选项,将两个分量相加:C¯result=Src+Dst
  • GL_FUNC_SUBTRACT:将两个分量相减: C¯result=Src−Dst
  • GL_FUNC_REVERSE_SUBTRACT:将两个分量相减,但顺序相反:C¯result=Dst−SrcC¯

混合与深度测试结合时,会出现问题。若一个物体深度值大于半透明物体,但在半透明物体后面渲染,深度测试不会管物体是不是半透明的,而是一刀切地把这个物体的片元全部丢弃了。

为了解决这一问题,我们必须把深度值大的物体放在渲染顺序的前面。

一般渲染顺序如下:

  1. 先绘制所有不透明的物体。(因为不透明物体无需混合,无所谓渲染顺序)
  2. 对所有透明的物体排序。
  3. 按顺序绘制所有透明的物体。

可以采用STL map自动排序的方式,管理所有透明物体:

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];
}

但这种方法也只是简单地以物体的中心值作为位置顺序。当物体形状很复杂时,这种方法就不太好了,需要手动微调。一种较高级的解决这类问题的技术叫做次序无关透明度(Order Independent Transparency, OIT)。

面剔除

每个封闭形状的面都有正反之分。在OpenGL中,通过三角形片段三个顶点的绘制顺序判断该三角形所在面的正反。默认情况下,逆时针顶点所定义的三角形为正向三角形。

背向观察者的面通常不会被渲染。如果能够取消这些面的渲染,程序速度将会提高约50%。

glEnable(GL_CULL_FACE)用于开启面剔除。

glCullFace(GLEnum mode)用于指定剔除的面。

  • GL_FRONT:剔除正面
  • GL_BACK:剔除背面
  • GL_FRONT_AND_BACK:正反面都剔除

glFrontFace(GLEnum mode)用于指定正向三角形的定义。

  • GL_CCW:逆时针三角形为正
  • GL_CW:顺时针三角形为正。

使用面缓冲时,必须确保顶点数据的定义是”逆时针为正“的顺序。否则会出现渲染错误。

帧缓冲

帧缓冲(Framebuffer)是所有屏幕缓冲(包括颜色缓冲、深度缓冲、模板缓冲)的集合。在默认情况下,我们的绘制操作都在默认帧缓冲的渲染缓冲上进行,默认帧缓冲由GLFW创建。通过创建帧缓冲,可以获得额外的Render Target。

使用glGenFramebuffers(int count, unsigned int *FBO)生成帧缓冲对象(Framebuffer Object)。使用glBindFramebuffer(GL_FRAMEBUFFER, unsigned int FBO)绑定FBO对象。

完成绑定后,所有读取、写入缓冲的操作都会影响当前绑定的帧缓冲。

可以通过绑定到GL_READ_FRAMEBUFFER或GL_DRAW_FRAMEBUFFER绑定到只读/只写的目标上。

只进行生成、绑定操作的帧缓冲是不完整的。我们可以通过布尔表达式glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE检查帧缓冲是否完整。

视口中的视觉输出对应的帧缓冲永远是默认帧缓冲(0号)。因此,当完成非默认帧缓冲的渲染(离屏渲染,Off-screen Rendering)时,一定要记得重新绑定默认帧缓冲(glBindFramebuffers(GL_FRAMEBUFFER, 0)),并且把不需要的帧缓冲对象删除(glDeleteFramebuffers(1,&FBO))。

为了使帧缓冲完整,我们需要给它绑定颜色附件(Attachment)。

附件是一个内存位置,它能作为帧缓冲的一个缓冲,类似于一个图像。附件分为纹理附件(Texture Attachment)和渲染缓冲对象附件(Renderbuffer Object)。

也可以这么理解:

纹理附件

当帧缓冲被附加上纹理附件时,对这个帧缓冲执行的所有指令都会被渲染到这个纹理上(类似于RenderTexture)。

使用类似创建纹理的方式创建纹理附件,随后将其绑定到帧缓冲。

unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
//无图片数据,data参数为NULL
//若附加深度缓冲纹理,Format和Internalformat参数应当为GL_DEPTH_COMPONENT
//若附加模板缓冲纹理,则为GL_STENCIL_INDEX
//若同时附加深度和模板缓冲纹理,Format为GL_DEPTH24_STENCIL8,Internalformat为GL_DEPTH_STENCIL,Type为GL_UNSIGNED_INT_24_8
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);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);

其中,glFramebufferTexture2D用于把纹理对象附加到帧缓冲上。具体参数有:

  • target:帧缓冲的目标(绘制、读取或者两者皆有)
  • attachment:我们想要附加的附件类型。除了颜色缓冲外,还有GL_DEPTH_ATTACHMENTGL_STENCIL_ATTACHMENTGL_DEPTH_STENCIL_ATTACHMENT
  • textarget:附加的纹理类型
  • texture:附加的纹理本身
  • level:多级渐远纹理的级别。

渲染缓冲对象附件

渲染缓冲对象(Renderbuffer Object,RBO)是真正的缓冲(相对于纹理等通用数据缓冲, General Purpose Data Buffer),它相比于纹理缓冲具有更快的读取速度。

渲染缓冲对象是只写的,但可以通过glReadPixels函数来读取当前绑定的帧缓冲中的特定像素。

如何理解上面这句话?

视口上呈现的视觉输出始终对应于默认帧缓冲,但无论是纹理附件还是渲染缓冲对象附件,改变的都是我们自己生成的帧缓冲。纹理附件可以以纹理的形式呈现在默认帧缓冲输出中,但渲染缓冲对象附件却不行,因为它不可读。但是,我们依然可以使用glReadPixels函数来访问被其改变后的帧缓冲的像素。

通过glGenRenderbuffers生成渲染缓冲对象,通过glBindRenderbuffer(GL_RENDERBUFFER, unsigned int RBO)绑定渲染缓冲对象。

当我们不需要从这些缓冲中采样的时候,通常都会选择渲染缓冲对象,因为它会更优化一点。

所谓“采样“指的是从纹理中读取像素数据,用于着色、纹理映射等操作。而测试所需要的数值比较并非采样,因此可以认为测试无需读取,可以使用RBO。

使用glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, SCREEN_WIDTH, SCREEN_HEIGHT)创建深度和模板RBO,随后使用glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, unsigned int RBO)附加渲染缓冲对象。

通常的规则是,如果你不需要从一个缓冲中采样数据,那么对这个缓冲使用渲染缓冲对象会是明智的选择。如果你需要从缓冲中采样颜色或深度值等数据,那么你应该选择纹理附件。

流程:创建帧缓冲->绑定帧缓冲->创建附件->配置附件->绑定附件->进行渲染操作->删除附件->解绑帧缓冲

渲染到纹理

  1. 首先,创建、绑定帧缓冲对象和纹理对象,并将纹理附加到帧缓冲。在这里,纹理附件用于存储颜色数据。
glGenTextures(1,&texture);
glBindTexture(GL_TEXTURE_2D,texture);
glTexImage2D(GL_TEXTURE_2D,0,GL_RGB,SCR_WIDTH,SCR_HEIGHT,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);
glFramebufferTexture2D(GL_FRAMEBUFFER,GL_COLOR_ATTACHMENT0,GL_TEXTURE_2D,texture,0);
  1. 然后,创建、绑定渲染缓冲对象,并附加到帧缓冲,注意帧缓冲对象的解绑。在这里,渲染缓冲对象用于存储深度和模板缓冲数据。
    unsigned int RBO;
    glGenRenderbuffers(1,&RBO);
    glBindRenderbuffer(GL_RENDERBUFFER,RBO);
    glRenderbufferStorage(GL_RENDERBUFFER,GL_DEPTH24_STENCIL8,SCR_WIDTH,SCR_HEIGHT);
    glBindRenderbuffer(GL_RENDERBUFFER,0);
    glFramebufferRenderbuffer(GL_FRAMEBUFFER,GL_DEPTH_STENCIL_ATTACHMENT,GL_RENDERBUFFER, RBO);
    if(glCheckFramebufferStatus(GL_FRAMEBUFFER)==GL_FRAMEBUFFER_COMPLETE) {
        cout<<"Framebuffer complete!"<<endl;
    }
    glBindFramebuffer(GL_FRAMEBUFFER,0);

上面两步是渲染循环之前,让帧缓冲对象变完整的过程。

  1. 接着,绑定刚才创建的帧缓冲对象,然后绘制场景。注意绘制完要解绑,回到默认帧缓冲。
glBindFramebuffer(GL_FRAMEBUFFER,framebuffer);
glClearColor(skyboxColor.x,skyboxColor.y,skyboxColor.z,1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
DrawScene();
glBindFramebuffer(GL_FRAMEBUFFER,0);
glDisable(GL_DEPTH_TEST); //绘制面片无需启用深度测试
  1. 然后,在默认帧缓冲绘制面片。
glClearColor(skyboxColor.x,skyboxColor.y,skyboxColor.z,1.0f);
glClear(GL_COLOR_BUFFER_BIT);
quadShader.use();
glBindVertexArray(quadVAO);
glBindTexture(GL_TEXTURE_2D,texture);
glDrawArrays(GL_TRIANGLES,0,6);

后处理

简单的后处理

  • 反向:One Minus 采样
  • 灰度:采样值的r、g、b相加除以3,作为新的r、g、b

核处理

使用卷积核对图像进行卷积。

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,  15, -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);
}

卷积核不同,处理的效果也不同。

  • 锐化:2 2 2 2 -15 2 2 2 2
  • 模糊:(1 2 1 2 4 2 1 2 1)/16
  • 边缘检测:1 1 1 1 -8 1 1 1 1

在对屏幕边缘的像素进行采样时,超出边缘的像素会按环绕方式进行采样。为了避免错误,需要设置为GL_CLAMP_TO_EDGE

立方体贴图

立方体贴图(Cube Map)是包含了六个2D纹理的纹理,通过三维方向向量(立方体中心为原点)进行采样。

img

创建、绑定立方体贴图的方法与2D纹理类似,只是目标GL_TEXTURE_2D要更改为GL_TEXTURE_CUBE_MAP

unsigned int textureID;
glGenTextures(1,&textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP,textureID);

OpenGL并没有专门为Cube Map提供输入数据的函数。但Cube Map本质上是六个2D纹理组成的纹理,所以可以通过调用六次glTexImage2D的方式输入数据。

Cube Map的每个面都有单独的Target,前缀都为GL_TEXTURE_CUBE_MAP,后缀依次为:POSITIVE_XNEGATIVE_XPOSITIVE_YNEGATIVE_YPOSITIVE_ZNEGATIVE_Z,分别对应右、左、上、下、后、前。它们作为unsigned int,依次递增1,因此可以用循环赋值。

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
    );
}

完成纹理数据输入后,同样需要设置纹理的过滤和环绕方式。与2D纹理不同的是,Cube Map在环绕方式上除了S、T还有R维度。它类似于三维空间中的Z轴,当方向矢量未击中任何面(如接缝处)时,返回边界值。

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

在GLSL中,samplerCube用于定义一个Cube Map采样器。它使用一个vec3(方向矢量)而非vec2作为采样坐标。

FragColor = texture(cubemap, textureDir)

通过将天空盒顶点着色器的gl_Position设置为原本的xyww,即可让顶点z轴,即深度值,始终等于一。并且要把glDepthFunc修改为GL_LEQUAL,让深度值等于1的天空盒片元能够通过测试。

需要注意,经过view和proj矩阵处理过过的gl_Position的z值始终在0-1之间。因此,z轴等于1时意味着这个顶点位于无限远处。因此,这样可以让所有物体都“位于”天空盒的前面。

对于view矩阵,通过取其左上方的3*3矩阵,可以移除位移效果。这使得天空盒不会随着玩家移动。

glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));

反射

使用视线方向的反射向量作为采样立方体贴图的方向向量。

img

折射

使用视线方向的折射方向作为采样立方体贴图的方向向量。

img

一些材质的折射率:

材质 折射率
空气 1.00
1.33
1.309
玻璃 1.52
钻石 2.42

GLSL内建函数refract包含三个参数,分别为:从视线出发的视线向量;法向量;1/折射率。前两个参数必须被归一化。

高级数据

调用glBufferData为缓冲目标分配内存并填充数据时,若参数data设置为NULL,就会仅分配内存而不填充。

glBufferSubData(TARGET, offset, length, *data)用于向已分配内存的TARGET缓冲区,距离头部指针offset字节的内存位置写入长度为lengthdata数据。

glBufferSubData提供了一种更简洁的写入数据的方式。

使用glBufferData写入数据时,我们必须确保单个顶点的各个属性在内存上是连续的。但用glBufferSubData,我们就可以把各属性作为单独的数组,分别调用glBufferSubData填充数据、调用glVertexAttribPointer指定顶点属性。例如:

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

glMapBuffer(TARGET, GL_WRITE_ONLY)返回一个void*指针,指向TARGET缓冲区的头部位置。可以使用memcpy函数向指针指向的内存空间写入数据。完成数据写入后,需要调用glUnmapBuffer(TAGRET)解除映射。该函数返回一个GL_BOOL值,若成功映射数据到缓冲,则为GL_TRUE,否则(如写入内存超过分配内存)返回GL_FALSE。该函数在直接从文件读入数据写入缓冲目标时很有用。

glCopySubData(READ_TARGET, WRITE_TARGET, readoffset, writeoffset, size)用于从READ_TARGET缓冲区距离头部readoffset字节的内存位置复制长度为size的数据到WRITE_TARGET缓冲区距离头部writeoffset字节的内存位置。

如果要复制的两个缓冲区类型相同,可以先把其中之一或者二者换为专用于复制的GL_COPY_WRITE_BUFFERGL_COPY_READ_BUFFER缓冲区。

高级GLSL

内建变量

顶点着色器

gl_PointSize:float输出变量,用于控制渲染GL_POINTS型图元时,点的大小。可以用于粒子系统。将其设置为gl_Position.z时,可以使点的距离越远,距离越大,创造出类似“近视眼看远处灯光”的效果。

gl_VertexID:int型输入变量(只读),存储了正在绘制顶点的ID(或索引ID,当使用glDrawElements时)。

片段着色器

gl_FragCoord:vec4型输出变量。存储了屏幕空间坐标(x、y,以窗口左下角为原点)和图元深度值(z,0-1)。常用于获取深度值,还有把窗口分为两部分进行不同渲染输出(RTX-ON/OFF)。

gl_FrontFacing:bool型输入变量。标记了当前图元是否为正面。用于对图元的正反面做不同处理。

gl_FragDepth:float型输出变量。用于手动设置片段的深度值。在片段着色器中出现后,Early-Z将被禁用。

接口块

我们使用inout关键字在着色器之间传递数据。除了单个变量外,这两个关键字也可以用来传递与结构体相似的接口块(Interface Block)。

//顶点着色器
out VS_OUT
{
    vec2 TexCoords;
} vs_out; //声明块名为VS_OUT,实例名为vs_out的接口块,内含一个vec2型变量。
//片段着色器
in VS_OUT{
    vec2 TexCoords;
} fs_in; //着色器之间传递接口块,块名应当相同,实例名可不同。

使用实例名.成员变量访问成员变量。

Uniform缓冲对象

在之前的程序中,我们每次渲染迭代都需要手动设置view、proj等uniform。为了简化操作,我们引入Uniform缓冲对象。它同样是一种OpenGL缓冲目标,在绑定后开辟一块内存区域。

对于Uniform缓冲对象,我们只需要给Shader传递一次值。随后,Shader便会自动采集缓冲区对应内存中各变量的值,自动变化,无需我们手动设置。

GLSL中,Uniform块用于采集Uniform缓冲对象中的数据。

layout (std140) uniform Matrices{
    mat4 proj;
    mat4 view;
};

其中,layout(std140)指定了Uniform块布局。默认情况下,Uniform块布局是Shared型,这类布局的各变量偏移量会随设备和系统的不同而变化。但我们希望Uniform块中各变量的偏移量能被手工计算出,以便让块内各变量能与UBO中各变量相对应。std140布局便是我们需要的。

在std140布局中,每个变量都有一个基准对齐量(Base Alignment),它是一个变量在Uniform块中占据的空间。每个变量还有一个对齐偏移量(Aligned Offset),它是一个变量从块起始位置的偏移量,它必须是Base Alignment的倍数。简而言之,前者是size,后者是offset。

类型 布局规则
标量,比如int和bool 每个标量的基准对齐量为N。
向量 2N或者4N。这意味着vec3的基准对齐量为4N。
标量或向量的数组 每个元素的基准对齐量与vec4的相同。
矩阵 储存为列向量的数组,每个向量的基准对齐量与vec4的相同。
结构体 等于所有元素根据规则计算后的大小,但会填充到vec4大小的倍数。

其中,4字节=1N

绑定点(Binding Point)可以理解为UBO的索引。每个绑定点都对应了一个UBO。每个UBO可以通过绑定点连接多个Uniform块。UBO的内容改变时,所有绑定了这个UBO的Uniform块都会改变。

img

通过glGetUniformBlockIndex(shaderID, uniformName)获取uniform块索引,再通过glUniformBlockBinding(shaderID, uniformblockIndex, bindingPtrIndex)将ID为shaderID的Shader中,索引为uniformblockIndex的uniform块绑定至绑定点bindingPtrIndex

随后,通过glBindBufferBase(GL_UNIFORM_BUFFER, bindingPtrIndex, UBO)借助句柄UBO将Uniform缓冲对象绑定至绑定点bindingPtrIndex

也可通过glBindBufferRange(GL_UNIFORM_BUFFER, bindingPtrIndex, UBO, UBOsize)绑定。

接着,通过glBufferSubData向缓冲区分区写入数据。

glBindBuffer(GL_UNIFORM_BUFFER, uboExampleBlock);
int b = true; // GLSL中的bool是4字节的,所以我们将它存为一个integer
glBufferSubData(GL_UNIFORM_BUFFER, 144, 4, &b);  //这里的144是Uniform块中成员变量boolean的对齐偏移量。
glBindBuffer(GL_UNIFORM_BUFFER, 0);

使用

//创建UBO
unsigned int UBO;
glGenBuffers(1,&UBO);
glBindBuffer(GL_UNIFORM_BUFFER,UBO);
glBufferData(GL_UNIFORM_BUFFER,152,NULL,GL_STATIC_DRAW);
//获取Uniform块索引
unsigned int UBI = glGetUniformBlockIndex(shader.ID,"Matrices");
//绑定块索引至绑定点
glUniformBlockBinding(shader.ID,UBI,0);
//绑定UBO到绑定点
glBindBufferBase(GL_UNIFORM_BUFFER,0,UBO);
//传输数据
glBufferSubData(GL_UNIFORM_BUFFER,0,sizeof(glm::mat4),value_ptr(projection));
//解绑
glBindBuffer(GL_UNIFORM_BUFFER,0);

使用UBO的好处主要在于:设置一个UBO,改变所有绑定的着色器中的块;提高着色器中允许存在的uniform数量。

几何着色器

几何着色器(Geometry Shader)位于顶点着色器和片段着色器之间,它的输入是一个图元的一组顶点,用于在将其发送到下一个着色器阶段前对其进行变换。几何着色器可以把一组顶点变化为不同的图元,也可以生成更多的顶点。

例子:

#version 330 core
//声明从顶点着色器传入的图元类型
//图元类型包括:points(GL_POINTS)、lines(GL_LINES/GL_LINES_STRIP)、lines_adjacency(GL_LINES_ADJACENCY/GL_LINE_STRIP_ADJACENCY)、triangles(GL_TRIANGLES/GL_TRIANGLE_STRIP/GL_TRIANGLE_FAN)、triangless_adjacency(GL_TRIANGLES_ADJACENCY/GL_TRIANGLE_STRIP_ADJACENCY)
layout (points) in;
//声明输出的图元类型。可接受points、line_strip、triangle_strip
//同时需要声明输出的最大顶点数
layout (line_strip, max_vertices = 2) out;

void main() {    
    //gl_in是一个内建接口块数组(因为图元不止一个顶点),其中包含gl_Position、gl_PointSize和gl_CLipDistance[]。
    //这里的gl_Position作为一个临时变量,用于存储新顶点的位置。
    gl_Position = gl_in[0].gl_Position + vec4(-0.1, 0.0, 0.0, 0.0); 
    //调用EmitVertex后,将在gl_Position所处的位置生成一个新顶点
    EmitVertex();

    gl_Position = gl_in[0].gl_Position + vec4( 0.1, 0.0, 0.0, 0.0);
    EmitVertex();
    //调用EndPrimitive后,Emit的顶点将被合成为指定的图元。
    EndPrimitive();
}

需要注意的是,传入的图元将不会被保留。

几何着色器可以用于可视化法线,或生成毛发。

可视化法线的例子:

//顶点
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNorm;
layout (location = 2) in vec2 uv;

out VS_OUT{
    vec3 norm;
}vs_out;

uniform mat4 model;
uniform mat4 proj;
uniform mat4 view;

void main()
{
    mat3 normalMatrix = mat3(transpose(inverse(view * model)));
    vs_out.norm = normalize(vec3(vec4(normalMatrix * aNorm, 0.0)));
    gl_Position = proj * view * model * vec4(aPos, 1.0);
}
//几何
#version 330 core
layout (triangles) in;
layout (line_strip, max_vertices = 6) out;
uniform float explodeStrength;
in VS_OUT{
    vec3 norm;
}gs_in[];

out vec2 TexCoords;

void main(){
    gl_Position = gl_in[0].gl_Position;
    EmitVertex();
    gl_Position = gl_in[0].gl_Position + vec4(gs_in[0].norm*explodeStrength,0);
    EmitVertex();
    EndPrimitive();
    gl_Position = gl_in[1].gl_Position;
    EmitVertex();
    gl_Position = gl_in[1].gl_Position + vec4(gs_in[1].norm*explodeStrength,0);
    EmitVertex();
    EndPrimitive();
    gl_Position = gl_in[2].gl_Position;
    EmitVertex();
    gl_Position = gl_in[2].gl_Position + vec4(gs_in[2].norm*explodeStrength,0);
    EmitVertex();
    EndPrimitive();
}
//片段
#version 330 core
out vec4 FragColor;

in vec2 TexCoords;

struct Material{
    sampler2D texture_diffuse1;
    sampler2D texture_specular1;
    float shininess;
};
uniform Material material;

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

实例化

对于在同一场景中使用相同顶点数据的对象(如草地中的草),可以使用实例化(Instancing)技术,用一个绘制函数让OpenGL绘制多个物体,而非循环(Drawcall: N->1)。

实例化技术本质上是减少了数据从CPU到GPU的传输次数。

使用glDrawArraysInstancedglDrawElementsInstanced函数代替没有Instanced的版本,即可使用实例化渲染。这个版本的绘制函数接收额外的Instance Count参数,用于设置一次渲染的实例个数。

顶点着色器内建变量gl_InstanceID保存了当前渲染图元所在的实例索引。借助该变量,我们可以根据实例ID的不同改变其位置、渲染方式等。常见的方法是将其作为uniform数组的索引。

但是,程序可向着色器传递的uniform数量是有限的。之前提到的UBO是一种解决方式。但在实例化渲染中,实例化数组(Instanced Array)是更好的方式。

实例化数组被定义为一个顶点属性,仅在渲染一个新实例时才会更新。

定义实例化数组与定义顶点属性类似:

#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec3 aColor;
layout (location = 2) in vec2 aOffset; //aOffset是一个实例化数组
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);
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(AttribIdx, Count)函数。这个函数定义了什么时候更新顶点属性的内容到新一组数据。Count参数为0时,每次顶点着色器运行都更新,即默认的方式;参数为1时,运行到每个实例时更新;参数为2时,每两个实例更新,以此类推。

以绘制十万个小行星为例:

首先,修改顶点着色器,便于实例化数组传入:

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNorm;
layout (location = 2) in vec2 uv;
layout (location = 3) in mat4 instanceMatrix;
//uniform mat4 model;
uniform mat4 proj;
uniform mat4 view;
out vec3 norm;
out vec3 fragPos;
out vec2 TexCoords;
void main()
{
    gl_Position = proj * view * instanceMatrix * vec4(aPos, 1.0);
    fragPos = (instanceMatrix*vec4(aPos,1.0f)).xyz;
    norm =  mat3(transpose(inverse(instanceMatrix)))*aNorm;
    TexCoords = uv;
}

然后,配置顶点属性,传入数据:

    unsigned int buffer;
    glGenBuffers(1, &buffer);
    glBindBuffer(GL_ARRAY_BUFFER, buffer);
    glBufferData(GL_ARRAY_BUFFER, ROCK_AMOUNT * sizeof(glm::mat4), &rockMatrices[0], GL_STATIC_DRAW);

    for(unsigned int i = 0; i < rock.meshes.size(); i++)
    {
        unsigned int VAO = rock.meshes[i].VAO;
        glBindVertexArray(VAO);
        // 顶点属性
        // 传入的是一个mat4,即4个vec4
        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);
    }

然后,绘制模型:

        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, ROCK_AMOUNT
            );
        }

抗锯齿

锯齿现象又称走样(Aliasing),抗锯齿技术又称反走样(Anti-aliasing)。

超采样抗锯齿(Super Sample Aniti-aliasing, SSAA)使用比正常分辨率更高的分辨率渲染常见,当图像输出在帧缓冲更新时,下采样(Downsample)到正常分辨率。额外的分辨率用来防止走样的产生。但由于渲染分辨率的提高,性能开销将变大。NxSSAA指的就是把原分辨率放大N倍渲染后降采样的SSAA。

多重采样抗锯齿(Multisample Aniti-aliasing, MSAA)是较为常见的抗锯齿方法。

光栅器是位于最终处理过的顶点之后到片段着色器之前所经过的所有的算法与过程的总和。光栅器会将一个图元的所有顶点作为输入,并将它转换为一系列的片段。顶点坐标与片段之间几乎永远也不会有一对一的映射,所以光栅器必须以某种方式来决定每个顶点最终所在的片段/屏幕坐标。

每个像素中心包含有一个采样点(Sample Point),当采样点位于三角形内部时,这个采样点对应的像素就会生成一个片段。

img

MSAA把像素的单一采样点变为多个按特定图案排列的四个子采样点(Subsample)。

img

无论三角形覆盖了多少子采样点,每个像素点都只会运行一次片段着色器。最终输出的片段依然位于像素中央,其y暗色由覆盖的子采样点数量决定。以4xMSAA为例,当三角形覆盖了一个像素的2个采样点时,其颜色就是0.5*三角形颜色+0.5*背景色。

本质上其实是每个子采样点都存储了颜色数据,在为像素计算片段颜色时将四个子采样点中的颜色做平均。

使用MSAA后,每个像素中都需要存储特定数量的颜色值。OpenGL中,多重采样缓冲(Multisample Buffer)用于存储特定数量的多重采样样本,替代原来的颜色缓冲。

使用glfwWindowHint(GLFW_SAMPLES,4)创建4x的多重采样缓冲。GLFW将自动为每个子采样点创建深度和样本缓冲,意味着所有缓冲的大小都增加了四倍。

使用glEnable(GL_MULTISAMPLE)开启MSAA。

离屏MSAA

当我们使用自己的帧缓冲时,需要手动生成多重采样缓冲。与帧缓冲类似,有纹理附件和渲染缓冲对象两种方式。

纹理附件

glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, tex);
glTexImage2DMultisample(GL_TEXTURE_2D_MULTISAMPLE, samples, GL_RGB, width, height, GL_TRUE);
glBindTexture(GL_TEXTURE_2D_MULTISAMPLE, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D_MULTISAMPLE, tex, 0);

渲染缓冲对象

glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, width, height);

绑定到多重采样帧缓冲后,任何绘制调用都会由光栅其负责多重采样运算。我们得到的多重采样缓冲包含了颜色、深度与模板缓冲。多重采样缓冲

多重采样缓冲不能直接用于着色器采样或深度、模板测试。因此,我们在绑定多重采样缓冲并完成绘制后,需要通过glBlitFrameBuffer函数将颜色等缓冲传递到其他帧缓冲上。例如,我们想把完成多重采样后的画面传输到默认帧缓冲上,进而显示在窗口上:

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

再比如说,我们向把完成多重采样的画面作为一个2D纹理,用于后处理等操作:

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();  

    ... 
}

自定义抗锯齿

GLSL中,sampler2DMS类型的uniform与texelFetch函数相结合可以用于获取每个子样本的颜色值:

uniform sampler2DMS screenTextureMS;
vec4 colorSample = texelFetch(screenTextureMS, TexCoords, 3); // 这里获取的是第4个子样本的颜色值

高级光照

Blinn-Phong

冯氏光照模型的镜面反射在反射度较低时会出现断层现象。这是因为:在观察向量和反射向量夹角(θ)超过90度时,反射度低的物体依然会把部分镜面高光传递到人眼中。但Phong模型却不会考虑这种情况,因为当θ超过90度时,点积结果将为负数,而由于max函数的存在,此时人眼接收到的实际镜面光为0。

img

为解决这个问题,我们引入Blinn-Phong光照模型。Blinn-Phong模型的镜面反射光与反射向量无关,而是采用半程向量(Halfway Vector,入射向量和视线向量的等分向量)与法向量的夹角计算镜面反射。这样可以确保夹角在0-90度之间,

img

半程向量 = normalize(入射方向+观察方向)

注意入射方向是片段指向光源;观察方向是片段指向观察者。

与Phong模型类似,Blinn-Phong的镜面光计算如下:

float spec = pow(max(dot(normal, halfwayDir), 0.0), shininess);
vec3 specular = lightColor * spec;

使用Blinn-Phong模型时,若想让呈现的结果与Phong模型类似,需要将反光度调高2-4倍。

Gamma校正

Gamma又称灰度系数,用于公式设备输出亮度=电压^Gamma。Gamma值越大,灰阶中的暗部过渡部分将会更加缓慢。人眼对暗部的感知力强于亮部,所以我们要让暗部变化更加平缓。人眼的Gaama值一般为2,而CRT一般为2.2。

img

理想状态下,Gamma为1,即图中的直线(代表了线性空间)。实线代表显示器对输出灰阶的自动校正。

假设我们想把暗红色(0.5,0.0,0.0)转变为纯红色(1.0,1.0,1.0),在线性空间中,只需要将其亮度变为原来的两倍即可。但在显示器上,由于Gamma校正,暗红色的实际RGB值为(0.218,0,0)。我们想要将其变为纯红色,需要把它的亮度翻4.5倍以上。

为了正确显示一个颜色,我们需要把这个颜色变得比原来更亮一些。这就需要引入Gamma校正技术。对于所有颜色,我们将其变为原来的(Gamma/1)次幂,如(0.5,0.0,0.0)变为(0.5,0.0,0.0)^(½.2)=(0.73,0.0,0.0)。随后,这个颜色经过屏幕的CRT Gamma处理,变为(0.73,0.0,0.0)^2.2 = (0.5,0.0,0.0),呈现出正确的颜色。

Gamma=2.2的空间被称为sRGB颜色空间。

使用glEnable(GL_FRAMEBUFFER_SRGB)开启OpenGL内建的Gamma校正功能。随后,每次片段着色器运行都将自动执行Gamma矫正操作。

Gamma校正始终应当在最后一步进行。

我们也可以自行在片段着色器内,根据Gamma校正的原理进行校正:

void main()
{
    // do super fancy lighting 
    [...]
    // apply gamma correction
    float gamma = 2.2;
    fragColor.rgb = pow(fragColor.rgb, vec3(1.0/gamma));
}

sRGB纹理

对于纹理创作者来说,他们创作的图片都是在sRGB空间中进行取色的(因为他们面对的屏幕就是sRGB的),所以这些图片本身就已经经过一次“人眼的Gamma校正”了。如果我们对这些纹理再进行一次Gamma校正,颜色就会出现偏差,变得更亮。

让纹理创作者在线性空间内创作显然不太现实。为此解决这一问题,OpenGL为我们提供了导入纹理时的sRGB纹理格式:GL_SRGBGL_SRGB_ALPHA。使用这两种格式导入时,OpenGL会把它们校正至线性空间。

在导入纹理时,我们必须小心地确定哪些是sRGB纹理。例如,漫反射贴图一半都是sRGB纹理,而法线和镜面贴图往往是线性纹理。

阴影

阴影贴图

一种直观的确认片段是否处于阴影内的方法是:得到射线第一次击中的点,然后把其他点和第一次击中点的位置进行对比。若离光源更近,则在光源下;若更远,则在阴影内。但射线上的点是无穷无尽的,所以不太现实。

上面说的这种方法的本质,就是根据片段与光源之间的距离关系来确定遮挡关系,这和之前的深度缓冲十分相似。我们使用帧缓冲,从光源的视角进行渲染,得到的深度缓冲值就反应了从光源的透视图下见到的第一个片段。我们把这个帧缓冲中的深度缓冲保存为一个纹理,这个纹理就叫做阴影贴图。

img

如图所示。尽管平行光光源位于无穷远,但为了渲染阴影,我们还是需要获取平行光的透视矩阵,所以我们需要为平行光光源设定一个位置。

使用来自光源的视图和投影矩阵(结合起来称为T变换)对场景进行渲染。

以右图为例。要渲染一个点P,通过T变换把P变换到光源的坐标空间,然后用变换后的坐标对阴影帧缓冲的深度缓冲进行索引。索引到的深度缓冲为0.4,即点C。但P点本身在光源坐标空间中的z值为0.5,大于深度缓冲,因此可以判断P点位于阴影内。

在OpenGL中使用阴影贴图渲染阴影的步骤如下:

首先,创建帧缓冲,附加2D纹理附件,并将其与帧缓冲的深度缓冲绑定。这个帧缓冲将存储光源视角下渲染场景得到的深度贴图。因为这里我们需要对深度值进行采样,所以使用纹理而非RBO。

GLuint depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);
//这两个值决定了深度贴图能包含的范围。过大会导致深度贴图的采样精度低,导致阴影锯齿增大;过小会导致“明明处于灯光下却没有阴影(或完全黑暗)”的情况出现。
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);
//设置为CLAMP_TO_BORDER可以让超出贴图纹理坐标范围的片段的深度值“被认为”是边缘的深度值,可以让一部分超出范围的片段“假装被照亮”,而非完全黑暗。
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); 
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
//设置材质参数为GL_CLAMP_TO_BORDER后,要记得设置borderColor,否则会默认使用黑色。
GLfloat borderColor[] = { 1.0, 1.0, 1.0, 1.0 };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
//显示地告诉OpenGL不需要更新和读取任何颜色缓冲
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

随后,开始生成深度贴图。

// 1. 首先渲染深度贴图
//注意调用glViewport,使生成的深度贴图尺寸合适。
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
//下面的函数用于把场景变换到光源的坐标系下
glm::mat4 lightView = glm::lookAt(-dirLightDir*3.0f,dirLightDir*3.0f,glm::vec3(0,1,0));
//改变border和near/far plane的值,可以调整深度贴图的尺寸大小
//一般,正交投影用于平行光;透视投影用于点光和聚光
glm::mat4 lightProj = glm::ortho(-10.0f,10.0f,-10.0f,10.0f,1.0f,10.0f);
glm::mat4 lightSpaceMatrix = lightProj*lightView;
lightSpaceShader.use();
lightSpaceShader.setMat4("lightSpaceMatrix",lightSpaceMatrix);
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();

从光源角度进行的渲染可以使用更简单的着色器以提升性能:

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

#version 330 core
void main()
{             
    //无需输出任何内容,可以留空
    // gl_FragDepth = gl_FragCoord.z;
}

然后,渲染深度缓冲的代码就变成了:

simpleDepthShader.Use();
shimpleDepthShader.setMat4("lightSpaceMatrix",lightSpaceMatrix);
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

于是我们就得到了阴影贴图。然后,开始正常渲染场景。新的顶点着色器:

out VS_OUT {
    vec3 FragPos; //世界空间片段位置
    vec3 Normal;
    vec2 TexCoords;
    vec4 FragPosLightSpace; //光空间片段位置
} vs_out;
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);
}
//一定注意:1-ShadowCalculation()的结果才是乘以(spec+diffuse)的参数!
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;
    //执行透视除法后,大于Far Plane的片段的z值大于1。对于这些片段,尽管我们没法计算其实际阴影,但我们也可以默认它们受到光照影响,将其shadow设置为0.0
    if(currentDepth>1.0f) shadow = 0.0
    return shadow;
}

阴影失真

img

非阴影区域的条纹状图样被称为阴影失真(Shadow Acne)。这是由于光源的“观察”方向与照射平面不垂直导致的。

img

如图,每个斜坡代表深度贴图一个像素,平面则代表被照射的物体。

橙色线段上的所有片段都从绿色线段代表的深度贴图像素上采样深度值。这个像素的深度值(后称贴图深度值)是橙、绿线段的交点。而左侧的橙色线段的深度值实际上是小于贴图深度值的,因此在着色器中,这个小段的片段被认为在阴影中(float shadow = currentDepth > closestDepth ? 1.0 : 0.0)。

为了解决这一问题,我们引入阴影偏移(Shadow Bias)。

img

float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;

我们略微增大所有片段的深度值,在直观上表现为“斜坡”和“平面”的交点左移。

(1.0 - dot(normal, lightDir)指的是,当物体表面法线和光源的夹角越大,最终的偏移值就越小,反之偏移值则越大。这使得偏移值可以很好地适应不同角度的斜坡。

阴影悬浮

应用阴影偏移时,当Bias值过大,会发生悬浮现象(Peter Panning),即阴影与实际的投影物并不相连。如下图:

img

解决这一问题只需要在光空间帧缓冲渲染时启用正面剔除即可,即glEnable(GL_CULL_FACE)glCullFace(GL_FRONT),原理如下:

img

需注意,这种方法仅对闭合物体生效。因为错误的阴影区域被渲染在了物体内部。

PCF

image-20240812125308326

阴影映射技术对深度贴图的分辨率有很大依赖。如果分辨率较小,那么多个片段采样的将是同一个深度值,这就导致了图上的阴影锯齿的产生。

直观的做法是提高阴影贴图分辨率,或者让光源视锥贴近场景,但这样会导致性能和内存开销。

百分比渐进过滤(Percentage-Closer Filtering,PCF)用于实现简单的阴影反走样。其核心思想类似于后处理中的模糊效果,对周边的深度值进行采样、叠加、平均。

float CalcShadow(sampler2D shadowMap){
    vec3 projCoords = fragLightPos.xyz/fragLightPos.w;
    projCoords = projCoords*0.5f+0.5f;
    //textureSize函数用于获取纹理分辨率。1/返回值可以得到单个像素在纹理坐标上的“长度”
    //我们可以手动更改size变量,使阴影更柔和
    vec2 size = 1.0/textureSize(shadowMap,0);
    float currentDepth = projCoords.z;
    float shadow = 0.0f;
    for(int x = -1; x <= 1; x++){
        for(int y = -1; y <= 1; y++){
            float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * size).r;
            shadow += currentDepth - shadowBias > pcfDepth ? 1.0 : 0.0;
        }
    }
    shadow /= 9.0f;
    if(currentDepth > 1.0f){
        shadow = 0.0f;
    }
    return shadow;
}

透视与正交

透视投影用于点光和聚光。但是,透视投影的深度值会被自动转变为非线性深度值。这会导致渲染出来的深度贴图基本“全白”。为了解决这一问题,需要将非线性深度值转换为线性。

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));
}

必须注意,转换操作仅适用于调试(例如输出深度贴图到屏幕以观察深度值),实际的渲染和采样操作无需转换

点光源阴影

对于平行光,我们只需要考虑一个方向。但点光源就需要考虑所有方向了。Cube Map这时候就派上用场了。

我们以点光源为原点,以90的FOV对六个方向进行深度图渲染,然后将其拼接为CubeMap,供方向向量采样。具体如下:

首先,创建立方体贴图,绑定至目标GL_TEXTURE_CUBE_MAP,然后创建六个数据值为NULL的2D纹理,映射到CubeMap的六个面上:

GLuint depthCubemap;
glGenTextures(1, &depthCubemap);
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);
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);
//注意CubeMap的R分量也要设置环绕方式
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

接着,绑定光空间渲染帧缓冲,并将CubeMap作为帧缓冲的纹理附件,类型为GL_DEPTH_ATTACHMENT

glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
// 当不需要颜色缓冲时,就调用下面两行
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

随后,进行光空间变换。由于CubeMap存在六个面,所以需要创建六个观察矩阵:

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

然后,把shadowTransforms中的六个变换矩阵发送到着色器。

在这里,我们需要使用几何着色器,将原来的三角形图元变换到六个坐标系下,并分别在这些坐标系下生成新的图元,供片段着色器使用。

对于顶点着色器,由于坐标变换在几何着色器中完成,所以只需要进行世界坐标变换即可。

#version 330 core
layout (location = 0) in vec3 position;
uniform mat4 model;
void main()
{
    gl_Position = model * vec4(position, 1.0);
}

几何着色器接收六元素的光坐标变换矩阵数组,对gl_Position进行坐标变换,然后发射顶点。

为什么要经过几何着色器,而非直接在片段着色器进行六次变换?

在片段着色器中处理六个不同的光变换矩阵会导致冗余计算,因为每个片段都需要判断其应属于哪个方向的阴影贴图。而几何着色器可以一次处理六个光方向变换,减少了片段着色器的调用次数,减少了性能开销。

但在某些显卡驱动上,几何着色器速度比较慢。所以得看具体情况。

#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices=18) out; //共计6*3=18个顶点
uniform mat4 shadowMatrices[6];
out vec4 FragPos;
void main(){
    for(int face = 0; face < 6; ++face){
        //gl_Layer指定了发送图元到CubeMap的哪个面
        gl_Layer = face;
        for(int i = 0; i < 3; ++i){
            FragPos = gl_in[i].gl_Position;
            gl_Position = shadowMatrices[face] * FragPos;
            EmitVertex();
        }    
        EndPrimitive();
    }
}

这一步是如何运作的?

我们想象一下,一个固定视角的摄像机。六个变换,都会把各自方向上应当渲染的物体变换到摄像机正前方。六次变换完毕后,相当于六个场景的物体都挤在一起,叠加在正前方。六个场景的物体都有一个Tag(gl_Layer,指定了当前图元在CubeMap中处于的面),每次只渲染其中的一个Tag到CubeMap上,直到六个面都渲染完为止。

接下来我们手动配置片段深度,在片段着色器中完成:

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

随后,CubeMap深度贴图生成完毕,进入渲染阶段。

与2D纹理深度贴图类似,使用glActiveTextureglBindTexture绑定CubeMap材质。

然后,修改顶点着色器,使其按照类似于无阴影时的方式,用MVP矩阵变换顶点坐标并传给片段着色器:gl_Position = projection * view * model * vec4(position, 1.0f);

对于片段着色器,将原本depthMap uniform的类型变更为samplerCube,并用FragPos作为采样向量即可。

新的阴影计算函数:

float ShadowCalculation(vec3 fragPos){
    vec3 fragToLight = fragPos - lightPos;
    //对CubeMap采样的方向向量无需归一化
    float closestDepth = texture(depthMap, fragToLight).r;
    //变换深度值从[0,1]到[0,far_plane]
    closestDepth *= far_plane;
    //手动计算当前深度值,即片段到光源的距离
    float currentDepth = length(fragToLight);
    float bias = 0.05; 
    float shadow = currentDepth -  bias > closestDepth ? 1.0 : 0.0;
    return shadow;
}

踩坑

  • 传递给几何着色器的光变换矩阵的投影矩阵的近平面不能过大,最好<0.5f。
  • CalculateShadow()函数的返回值应当One Minus后再乘以各光照分量。
  • 光空间帧缓冲渲染所用的顶点着色器只需要对传入的顶点位置做世界空间变换。
  • 渲染光空间帧缓冲时,需要调整glViewPort至阴影贴图的分辨率。

PCF

与单阴影贴图类似,万向阴影贴图同样存在由于贴图分辨率导致的阴影走样问题。可以使用PCF技术,对周围像素采样、平均,减少走样程度:

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

然而,上面的代码对每个片段都需要采样4*4*4=64次,性能开销大。我们需要抛弃一些彼此距离非常近的采样点,转而使用彼此分的比较开的采样点。为此,我们设定一个采样方向数组,里面包含了若干个vec3,彼此分离的很开,并且指向不同的方向。

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)
);
float shadow = 0.0;
float bias = 0.15;
int samples = 20;
float viewDistance = length(viewPos - fragPos);
//此处diskRadius就是offset
//这里采样的技巧是,当观察者距离片段越远,阴影越柔和,否则越锐利
float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0; 
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);

法线贴图

法线贴图(Normal Map)用于对顶点的法线作手动偏移。

类似于漫反射和镜面贴图,法线贴图是一个2D纹理,它的每个像素的颜色是一个vec3(颜色的r、g、b值,刚好是三个分量),代表着使用此处纹理坐标采样的片段的法线。

法线向量的范围在[-1,1],而RGB颜色的值在[0,1],所以要先进行处理:

vec3 rgb_normal = normal * 0.5 + 0.5; // 从法线向量转换为颜色向量
// 或
vec3 normal = (rgb_normal - 0.5) / 0.5 //从颜色向量转换为法线向量

法线贴图看起来是这样的:

img

一般法线贴图都偏向蓝色,因为正对“屏幕外”的法线指向(0,0,1),即Z轴,这是一种偏向蓝色的颜色。对于偏“上方”的法线,指向Y轴(0,1,0),就偏绿。

但是,要想让复杂模型正确应用法线贴图,就需要把采样得到的法线值进行坐标变换。

法线贴图中存储的法线向量处于切线空间(Tangent Space)。在切线空间中,法线永远指向正Z方向。

将切线空间的Z方向和顶点法线方向对齐的变换矩阵叫做TBN矩阵(Tangent、Bitangent、Normal)。顾名思义,构建此矩阵需要三个向量:法线向量N、切线向量T、副切线向量B。

img

法线向量可以直接从顶点属性中获取。其余两个向量需要计算获得。

img

如图,三角形边E1、E2可以被看做是切线向量T和副切线向量B的线性组合:

image-20240815142253689

转换为矩阵乘法:

image-20240815144221678

变换(乘逆矩阵)可得:

image-20240815171907132

由此,可计算出T和B。

代码形式如下:

// 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);
glm::vec3 edge1 = pos2 - pos1; //E1
glm::vec3 edge2 = pos3 - pos1; //E2
glm::vec2 deltaUV1 = uv2 - uv1; //ΔU
glm::vec2 deltaUV2 = uv3 - uv1; //ΔV
GLfloat f = 1.0f / (deltaUV1.x * deltaUV2.y - deltaUV2.x * deltaUV1.y);
//计算三角形1的切线与副切线
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);  

[...] // 对平面的第二个三角形采用类似步骤计算切线和副切线

我们把计算得到的切线、副切线向量作为顶点属性传递给顶点着色器,供顶点着色器构造TBN矩阵:

#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; //实际上,副切线向量可以用cross(T,N)得到,无需传递
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矩阵传递至片段着色器,将采样得到的法线乘以TBN矩阵,将其从切线空间变换到世界空间。但这种方式在片段着色器中引入了矩阵乘法,增加了许多性能开销。

第二种方式是在顶点着色器中,首先获取TBN矩阵的逆矩阵(由于TBN正交,所以获取其transpose矩阵即可),然后用这个逆矩阵把光照方向、观察方向等相关向量变换到切线空间,再传递给片段着色器。

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));
    [...]
}

Assimpt API

使用Assimp的importer进行模型导入时,使用aiProcess_CalcTangentSpace的Flag,即可自动为每个顶点计算切线与副切线向量,并使用aiMesh类的mTangents[i]获取切线向量,使用mBitangents[i]获取副切线向量。

const aiScene* scene = importer.ReadFile(
    path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace
);

Assimp无法使用aiTextureType_NORMAL加载.obj的法线贴图,而是得使用aiTextureType_HEIGHT

施密特正交化

当法线贴图应用到高面数模型时,通过类似于采样周边像素的方式对法线进行平均化能获得较平滑的结果。但是,这会导致TBN矩阵并非正交矩阵。

可以使用施密特正交化让TBN向量重新相互正交。

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)

视差贴图

法线贴图只是对光照的欺骗操作,而视差贴图(Paralax Mapping)则让平面贴图真正“拥有”深度。

视差贴图与光照无关,它根据存储在纹理中的几何信息对顶点进行偏移。

然而,一个平面基本不会有那么多顶点用于偏移。实际上,视差贴图是通过修改纹理坐标来让片段看起来比实际的更高或者更低的。如图:

img

红线代表视差贴图中的数值,V代表ViewDir,A代表片段位置。视差贴图的目的是,渲染片段A时,不再使用A处的纹理坐标,而是使用B处的。实现方式如下:

img

采样视差贴图的A处,可以得到H(A),代表着对viewDir向量(原V向量)进行缩放后的长度。缩放后得到向量P。

随后,得到向量P在视差贴图上的投影,取其长度作为纹理坐标偏移值。

为什么这个能work?

对于现实中的物体,我们看向A处时,实际上只会看到B。我们通过这种方法偏移纹理坐标,最终采样的位置十分接近B。H(P)点与B越接近,效果越接近现实。

但是,我们在视差贴图的采样过程中引入了另一个坐标空间,在这个空间中,P向量的x、y分量总是与贴图表面对齐。当表面被旋转后,由于坐标空间未变化,我们会得到错误的结果。

为了解决这一问题,视差贴图往往是在切线空间实现的。我们要做的就是把V向量转换到切线空间。

实现

视差贴图需要与法线贴图结合使用,因为如果光照与视差效果不匹配,就很出戏了。

一般情况下,我们用“深度”图而非“高度”图作为视差贴图。如下:

视差贴图示意

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    float height =  texture(depthMap, texCoords).r;    
    vec2 p = viewDir.xy / viewDir.z * (height * height_scale);
    return texCoords - p;    
}

上面是一种相对简单的视差函数实现。其中,viewDir.xy / viewDir.z求取的是viewDir在z轴方向的“倾斜度”。当viewDir与纹理表面越平行,获取的偏移向量p就越大。这个函数返回偏移以后的纹理坐标,我们使用这个纹理坐标采样漫反射贴图。

陡峭视差映射

在表面高度变化较大的情况下,会出现一些不好的结果。原理图如下:

img

当蓝点与H(P)点距离越大时,画面表现就越不真实。为解决这一问题,我们引入陡峭视差映射(Steep Parallax Mapping)技术:

img

SPM技术的基本思想是,把总深度范围划分为若干层,每采样一次,便移动纹理坐标,并下降一层继续采样,直到采样深度小于当前层的深度值为止。

vec2 ParallaxMapping(vec2 texCoords, vec3 viewDir)
{ 
    const float minLayers = 8;
    const float maxLayers = 32;
    float numLayers = mix(maxLayers, minLayers, abs(dot(vec3(0.0, 0.0, 1.0), viewDir)));
    float layerDepth = 1.0 / numLayers;
    float currentLayerDepth = 0.0;
    vec2 P = viewDir.xy * height_scale; 
    vec2 deltaTexCoords = P / numLayers;
    vec2  currentTexCoords     = texCoords;
    float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
    while(currentLayerDepth < currentDepthMapValue)
    {
        currentTexCoords -= deltaTexCoords;
        currentDepthMapValue = texture(depthMap, currentTexCoords).r;  
        currentLayerDepth += layerDepth;  
    }
    return currentTexCoords;
}

视差遮蔽映射

视差遮蔽映射(Parallax Occlusion Mapping)用于减少陡峭视差映射的断层现象。其原理与陡峭视差映射相同,但并非使用第一次纹理采样值大于采样层深度的采样层深度作为纹理坐标偏移量,而是:当满足条件时,使用当前层的深度和上一层深度的比例关系作为插值权重,对当前层对应的纹理坐标和上一层对应的纹理坐标进行插值。如图:

img

代码如下:

[...]
vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
float afterDepth  = currentDepthMapValue - currentLayerDepth;
float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
float weight = afterDepth / (afterDepth - beforeDepth);
vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);
return finalTexCoords;

HDR

当场景内存在多个高强度光源时,会导致相当一部分片段的颜色值大于1.0。而大于1.0部分的颜色值会被约束在1.0,因此会出现一片一片糊在一起的白色区域。

一种方法是减少场景内的光源强度,但这会引入不切实际的光照参数。更好的方法是允许颜色值超过1.0,然后将其映射到[0,1]之内。

通过使用高动态范围(High Dynamic Range,HDR),我们可以让片段的颜色超过1.0。首先,我们在光照方程中用更大范围的颜色值渲染,然后将所有HDR值转换到低动态范围(Low Dynamic Range, LDR)内。这个转换的过程叫做色调映射(Tone Mapping)。

在实时渲染中,HDR不仅允许让颜色值超过LDR范围,也允许我们根据光源的真实强度指定其强度。

浮点帧缓冲

对于使用标准化定点格式(如GL_RGB)的颜色缓冲,OpenGL会在将这些值存入帧缓冲前将其自动约束到LDR范围。当一个帧缓冲的颜色缓冲的内部格式被设置为GL_RGB16FGL_RGBA16FGL_RGB32FGL_RGBA32F时,该帧缓冲便被称为浮点帧缓冲(Floating Point Buffer)。它可以存储超过LDR范围的浮点值。

glBindTexture(GL_TEXTURE_2D, colorBuffer);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, SCR_WIDTH, SCR_HEIGHT, 0, GL_RGB, GL_FLOAT, NULL);  
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();

色调映射

色调映射(Tone Mapping)用于在损失很小的前提下将浮点颜色值转换至LDR范围,并且伴有特定风格的色平衡(Stylistic Color Balance)。

Reinhard色调映射是最简单的色调映射。它平均地将所有亮度值分散到LDR上,并且通常伴随Gamma矫正过滤:

void main()
{             
    const float gamma = 2.2;
    vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
    vec3 mapped = hdrColor / (hdrColor + vec3(1.0));
    mapped = pow(mapped, vec3(1.0 / gamma));
    color = vec4(mapped, 1.0);
}   

曝光(Exposure)参数用于设置不同光照条件下的光照参数,让画面无论是在高亮度条件还是低亮度条件下都能较好地呈现:

uniform float exposure;
void main()
{             
    const float gamma = 2.2;
    vec3 hdrColor = texture(hdrBuffer, TexCoords).rgb;
    vec3 mapped = vec3(1.0) - exp(-hdrColor * exposure);
    mapped = pow(mapped, vec3(1.0 / gamma));
    color = vec4(mapped, 1.0);
}

泛光

泛光(Bloom)是后处理技术的一种,用于在光源附近加上一层光晕。

泛光常常与HDR技术结合使用。其基本原理如下:

对于HDR颜色缓冲纹理,提取所有超过一定亮度的片段,得到一个新纹理。随后对这个新闻里进行高斯模糊处理,然后叠加到原来的颜色缓冲纹理上。

img

提取亮色

多渲染目标(Multiple Render Targets, MRT)用于指定多个片段着色器输出。

使用MRT的必要条件是当前的帧缓冲附加有多个颜色附件

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);
    glFramebufferTexture2D(
        GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0 + i, GL_TEXTURE_2D, colorBuffers[i], 0
    );
}
GLuint attachments[2] = { GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1 };
//glDrawBuffers用于告知OpenGL渲染到哪些颜色缓冲,默认仅渲染到GL_COLOR_ATTACHMENT0
glDrawBuffers(2, attachments);

在片段着色器中使用layout关键字定义多个颜色缓冲输出:

#version 330 core
layout (location = 0) out vec4 FragColor;
layout (location = 1) out vec4 BrightColor;
[...]
void main()
{            
    [...]
    FragColor = vec4(lighting, 1.0f);
    float brightness = dot(FragColor.rgb, vec3(0.2126, 0.7152, 0.0722));
    if(brightness > 1.0)
        BrightColor = vec4(FragColor.rgb, 1.0);
}

高斯模糊

高斯模糊(Gaussian Blur)是一种基于核的后处理技术。高斯曲线是一种钟形曲线,将其扩展到二维便可以形成一个卷积核的权重。但这样做需要采样很多次,极大消耗性能。

为此,我们可以把二维方程拆解为两个一维方程,一个描述水平权重,一个描述垂直权重。如图:

img

该方法称为两步高斯模糊(Two-Pass Gaussian Blur)。我们用帧缓冲对象实现“两步”:在第一个帧缓冲对象进行水平模糊,然后将纹理传入第二个帧缓冲对象渲染时所用的着色器,进行垂直模糊。

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D image;
uniform bool horizontal; //使用bool指定水平还是垂直渲染
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);
    vec3 result = texture(image, TexCoords).rgb * weight[0];
    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);
}
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
    );
}
GLboolean horizontal = true, first_iteration = true;
GLuint amount = 10;
shaderBlur.Use();
//这里的循环与Shader内的循环不同,后者是用于卷积核采样的,前者是进行高斯模糊的次数,越大效果越好
for (GLuint i = 0; i < amount; i++)
{
    glBindFramebuffer(GL_FRAMEBUFFER, pingpongFBO[horizontal]); 
    glUniform1i(glGetUniformLocation(shaderBlur.Program, "horizontal"), horizontal);
    glBindTexture(
        //此处colorBuffers存放着MRT,其中1号元素代表对HDR画面进行强度筛选后的RT
        //如果是第一次循环,直接采用初始RT
        //否则,水平和垂直模糊交替进行
        //需注意,pingpongBuffers是blur帧缓冲绑定的颜色纹理附件,每次渲染到帧缓冲,该纹理附件都会变更为渲染后的画面
        //绑定FBO时的数组下标与pingpongBuffers的数组下标相反,代表着颜色数据的交换传输
        GL_TEXTURE_2D, first_iteration ? colorBuffers[1] : pingpongBuffers[!horizontal]
    ); 
    RenderQuad();
    horizontal = !horizontal;
    if (first_iteration)
        first_iteration = false;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

随后,在片段着色器中定义两个sampler2D,一个是原始画面,一个是经模糊的HDR筛选画面,将二者采样后相加即可得到最终颜色。

延迟着色

目前为止我们使用的光照方式皆为前向渲染(Forward Rendering),在场景中我们根据所有光源渲染一个物体,然后再渲染下一个物体,以此类推。但是,这种方式对于每一个需要渲染的物体,程序都需要对每一个光源进行迭代。当片段和光源数量持续增加时,需要迭代的次数将会爆炸式增长。

为了解决这一问题,我们引入延迟渲染法(Deferred Renderring)。该方法将光照等计算量较大的过程推迟到后期进行。延迟渲染分为两个过程(Pass),几何处理Pass和光照处理Pass。

在几何处理Pass中,首先渲染场景一次,以获取各类几何信息(如位置、颜色、法线、镜面值),并将其存储在被称为G缓冲(G-Buffer)的纹理中。

在光照处理Pass中,使用G缓冲中的数据(而非顶点着色器传入的数据)对每个片段计算场景光照。这种方法避免了无Early-Z情况下的单像素多次渲染,确保了每个像素只调用一次片段着色器。

img

延迟渲染的缺点是:

  • 消耗显存多,因为G-Buffer中要存储大量数据
  • 因为只有最前方的片段信息,所以无法使用Blend渲染半透明物体
  • 因为需要MRT技术支持,延迟渲染需要的RT数量极多,如果使用MSAA的话每个RT的分辨率都要翻倍,需要消耗极大的显存,所以一般说延迟渲染不支持MSAA

G缓冲

G-Buffer用来存储所有跟光照有关的数据,包括:

  • 3D位置向量
  • RGB漫反射颜色向量,即反照率(Albedo)
  • 3D法向量
  • 镜面强度浮点值
  • 光源位置与颜色向量
  • 相机位置

其中,后两点对于所有的片段都相同,可以通过uniform设置。而剩余变量对于每个变量都不同,所以可以使用G-Buffer传输。

使用MRT技术,每个变量对应一个RT。过程如下:

初始化FBO,它包含了若干颜色缓冲和一个深度缓冲对象。位置和法向量使用高精度纹理,反照率和镜面值使用默认精度。

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

// - 颜色 + 镜面颜色缓冲
//使用GL_RGBA,其中RGB通道渲染颜色,A通道存储镜面强度
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),并检查完整性
[...]

完成几何处理阶段后,进入光照处理阶段。

此阶段,首先渲染一个铺屏四边形,然后在四边形的每个像素上运行一次片段着色器:

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();  
#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);
}  

结合延迟与前向渲染

为了克服延迟渲染的缺点(如无法Blend),一般采用延迟与正向渲染相结合的方式。

对于光源,我们无需考虑其受光照影响,所以首先渲染光立方体。但渲染完毕后,我们发现,因为没有获取到延迟渲染中的深度信息,光立方体无法正常进行深度测试。为此,我们需要把延迟渲染的几何处理阶段中存储的深度信息输出到默认帧缓冲的深度缓冲中。

使用glBlitFrameBuffer函数将帧缓冲的内容复制到另一个帧缓冲:

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

类似地,我们也可以传输其他缓冲到默认帧缓冲。

光体积

延迟渲染本身并不能支持大量光源,因为即便是在铺屏四边形的逐像素渲染中,假设有1000个光源,我们还是要考虑每个光源对像素的贡献,无论光源有多远。为了解决这一问题,我们引入光体积(Light Volumns)技术:

光体积的基本原理是计算光源的半径,即光能够到达片段的范围。计算光照贡献时,我们只需要考虑覆盖在各个光源范围内的片段就可以了。

计算光源半径

在之前计算点光源衰减时,我们使用了常量+线性量+平方量的高级衰减方程。

\(F_{light} = \frac{I}{K_c + K_l * d + K_q * d^2}\)

其中,\(F_{light}\)是当前片段受光照影响的程度,\(I\)代表光照强度值(对于白光光源一般是1.0)。

但这个方程永远不会真正等于0,所以我们需要选择一个合适的“黑暗阈值”。在这里,我们使用5/256作为阈值。

\[\frac{5}{256} = \frac{I_{max}}{K_c + K_l * d + K_q * d^2}\]

其中,\(I_{max}\)代表光源中RGB中最强的光照分量,我们要做的是求出\(d\)的值。

通过求根公式我们可以求出:

\(d = \frac{-K_l + \sqrt{K_l^2 - 4 * K_q * (K_c - I_{max} * \frac{256}{5})}}{2 * K_q}\)

转换为代码:

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);  
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){
            // 执行大开销光照
            [...]
        }
    }   
}

但是问题是,着色器运行时总是会执行if语句所有的分支,因为GPU上的着色器程序是高度并行的,必须要确保每次运行着色器的代码都是一样的。因此,上面的着色器代码中的if判断实际上无用

为了解决这一问题,我们可以使用延迟渲染的方式,将每个光源的光体积球渲染出来,将其作为一个单独的RT,然后在实际计算光照时采样这个RT即可。

延迟光照(Deferred Lighting)切片式延迟着色法(Tile-based Deferred Shading)是比渲染光体积更优的方法,并且这两种方法允许MSAA。

SSAO

Phong模型中的环境光照(Ambient Lighting)常量用于模拟光的散射(Scattering)。但在现实里,光线会以任意方向散射,它的强度会不断发生改变。环境光遮蔽(Ambient Occlusion)用于模拟更真实的间接光,它通过将褶皱、孔洞和非常靠近的墙面所处的片段变暗来模拟出间接光照。

img

实现真正意义上的环境光遮蔽需要对每个片段周围的几何体的情况进行确认,开销较大。为解决这一问题,我们引入屏幕空间环境光遮蔽(Screen-Space Ambient Occlusion, SSAO)技术,该技术使用屏幕空间的场景深度而非真实几何体数据来确定屏蔽量。

SSAO的底层原理是:对于铺屏四边形上的每个片段,都根据周围的深度值计算一个遮蔽因子(Occlusion Factor)。这个遮蔽因子在之后用于抵消片段的环境光照分量。

遮蔽因子通过采集片段周围球形核(Kernel)的多个深度样本,并和当前片段的深度值对比得到。高于片段深度值样本的个数就是遮蔽因子。

img

如图,黑色折线代表几何体表面,球形代表核的范围。灰色采样点代表位于几何体内部的采样点,它们能对遮蔽因子产生影响。灰色样本越多,遮蔽因子越大,简介光照越暗。

对于这种方式,采样的样本数量和最终效果有着非常直接的关系。若样本数量太低,精度就会急剧减少,导致波纹(Banding)效果;若样本数量太高,又会影响性能。

为此,我们通过给采样核心(Sample Kernel)引入随机性,从而在减少样本数的情况下优化效果。但是随机性会引入噪点,因此,给结果叠加一个模糊处理效果会更好。如图:

img

即便是平整的墙面,球形采样核心也会囊括入约一半的采样点。但只有狭窄、内缩的表面才需要进行遮蔽。因此,我们使用沿表面法向量的半球形采样核心(被称为法向半球体,Normal-oriented Hemisphere)。如图:

img

样本缓冲

使用SSAO,我们需要获取以下数据:

  • 片段位置向量
  • 片段法线向量
  • 片段反射颜色
  • 采样核心
  • 用于旋转采样核心的随机旋转向量

SSAO是一种屏幕空间技术,因此不难发现,它非常适合在延迟渲染管线中使用,因为它可以直接通过G-Buffer获取所有需要的屏幕空间数据。

我们在片段着色器中,使用内建变量gl_FragCoord.z获取非线性片段深度,并可以将其变为线性深度值:

float LinearizeDepth(float depth){
    float z = depth * 2.0 - 1.0; // 回到NDC
    return (2.0 * NEAR * FAR) / (FAR + NEAR - z * (FAR - NEAR));    
}

提取的线性深度基于观察空间,因此需要确保G-Buffer中的位置和法线都处于观察空间。

我们也可以通过深度值重构位置向量,这是一种优化技巧:

Position From Depth 3: Back In The Habit – The Danger Zone (wordpress.com)

法向半球

对每个表面法线方向生成采样核心比较困难,所以我们会在切线空间生成采样核心,所有核心的朝向都指向正z方向,即表面法向量。

std::uniform_real_distribution<GLfloat> randomFloats(0.0, 1.0); // 随机浮点数,范围0.0 - 1.0
std::default_random_engine generator;
std::vector<glm::vec3> ssaoKernel;
for (GLuint i = 0; i < 64; ++i)
{
    //向x和y方向随机偏移(-1,1)
    glm::vec3 sample(
        randomFloats(generator) * 2.0 - 1.0, 
        randomFloats(generator) * 2.0 - 1.0, 
        randomFloats(generator) //z方向不能为负,因为是半球
    );
    sample = glm::normalize(sample);
    sample *= randomFloats(generator); //用于调整向量长度,使向量终点在半球内均匀分布
    GLfloat scale = GLfloat(i) / 64.0; 
    scale = lerp(0.1f, 1.0f, scale * scale); //缩放因子用于调整向量的分布方式,这里使得采样点更靠近片段原点
    sample *= scale;
    ssaoKernel.push_back(sample);  
}

随机核心转动

因为我们不可能在每次片段计算时都生成随机数,所以上面提到的带有随机性的采样核心会被运用至所有片段,但这会导致随机性偏少。

为了解决这一问题,我们创建一个随机旋转向量纹理并将其平铺至屏幕,通过采样来获取旋转向量。

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);
}
GLuint noiseTexture; 
glGenTextures(1, &noiseTexture);
glBindTexture(GL_TEXTURE_2D, noiseTexture);
//这个纹理本身只有4*4,我们通过设置环绕方式为GL_REPEAT来将其平铺在屏幕上
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);

SSAO着色器

SSAO着色器计算的是逐片段的遮蔽值而非最终的颜色,因此我们使用一个独立的帧缓冲计算SSAO结果,并将结果存储至纹理附件供后续叠加使用。

为什么不用MRT?

SSAO与最终颜色输出之间存在先后关系,凡是存在先后关系的都不能用MRT。

GLuint ssaoFBO;
glGenFramebuffers(1, &ssaoFBO);  
glBindFramebuffer(GL_FRAMEBUFFER, ssaoFBO);
GLuint ssaoColorBuffer;
glGenTextures(1, &ssaoColorBuffer);
glBindTexture(GL_TEXTURE_2D, ssaoColorBuffer);
//InnerFormat参数为GL_RED,因为SSAO结果值为灰度值
//尽管Format参数是GL_RGB,但由于源数据为NULL,所以这个参数取什么值都不会影响结果
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);

SSAO完整渲染流程如下:

// 几何处理阶段: 渲染到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);
    glActiveTexture(GL_TEXTURE1);
    glBindTexture(GL_TEXTURE_2D, gNormal);
    glActiveTexture(GL_TEXTURE2);
    glBindTexture(GL_TEXTURE_2D, noiseTexture);
    SendKernelSamplesToShader();
    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();

SSAO着色器代码:

#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(){
    // 采样G-Buffer数据
    vec3 fragPos = texture(gPositionDepth, TexCoords).xyz;
    vec3 normal = texture(gNormal, TexCoords).rgb;
    vec3 randomVec = texture(texNoise, TexCoords * noiseScale).xyz;
    // 有了数据后,可计算TBN矩阵,将基于切线空间的SSAO向量转换到观察空间
    // TBN向量位于哪个空间,他就可以把坐标从切线空间转换到哪个空间
    // 这里使用了施密特正交化,让TBN矩阵形成的正交基根据randomVec值略微倾斜
    vec3 tangent = normalize(randomVec - normal * dot(randomVec, normal));
    vec3 bitangent = cross(normal, tangent);
    mat3 TBN = mat3(tangent, bitangent, normal);
    sample = fragPos + sample * radius; 
    float occlusion = 0.0; //最终的遮蔽值
    //kernelSize用于调整采样点个数,默认64
    for(int i = 0; i < kernelSize; ++i){
        // 获取样本位置(本质上是为了获取采样点的UV坐标)
        vec3 sample = TBN * samples[i]; // 切线->观察空间
        //radius用于调整采样半球半径,默认1.0
        sample = fragPos + sample * radius; 
        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的值域
        //取反是因为,sample.z是观察空间下的深度值,越小越远
        float sampleDepth = -texture(gPositionDepth, offset.xy).w;
        //注意,这里sampleDepth和sample.z都是负数,越小,代表越远
        //深度差超过radius时,被限制到1.0,否则无论深度差多大,处于背景物体的片段都会产生AO
        float rangeCheck = smoothstep(0.0, 1.0, radius / abs(fragPos.z - sampleDepth));
        occlusion += (sampleDepth >= sample.z ? 1.0 : 0.0) * rangeCheck;        
    }
    //最终的遮蔽值是用于缩放光照分量的,所以遮蔽越强,occlusion越接近0
    occlusion = 1.0 - (occlusion / kernelSize);
    FragColor = occlusion;
}

环境遮蔽模糊

现在这版会由于采样点数量以及随机性的限制,出现噪点。所以我们需要对SSAO输出进行模糊。

由于存在先后关系(先SSAO,后Blur),所以使用FBO:

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

应用

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

PBR

理论

PBR,即基于物理的渲染(Physically Based Rendering),为一系列物理正确的渲染技术集合,以科学的方式描述材质与光照。

PBR遵循三大条件

  • 基于微平面(Microfacet)的表面模型
  • 能量守恒
  • 应用基于物理的BRDF

微平面模型

根据微平面理论,任何表面在达到微观尺度后都可以用被称为微平面的微小镜面表示。表面越粗糙,组成该表面的微平面的指向分布变化越大。

image-20241224123832698

指向分布变化越大,入射光线在表面就越趋向向不同的方向发散,导致分布更宽泛的镜面反射。

我们使用粗糙度(Roughness)属性以统计学的方式描述表面的微平面分布混乱程度。我们可以基于一个平面的粗糙度来计算出众多微平面中,朝向方向沿着某个向量\(h\)方向的比例。该向量\(h\)为此表面的半程向量

$\(h=\frac{l+v}{||l+v||}\)\(,其中\)l\(为入射光方向,\)v$为视线方向。微平面的朝向方向与半程向量的方向越是一致,镜面反射的效果就越是强烈越是锐利。

能量守恒

出射光线的能量永远不能超过入射光线的能量(发光面除外)

因此,粗糙度增大时,尽管镜面反射的区域增大了,但相应地,镜面反射的亮度也变小了。

能量守恒的一个关键方面在于对折射光和反射光做出了明确的区分。反射光会直接被表面反射,不进入表面内部;折射光则回进入表面内部,与表面内部的物质粒子进行碰撞,随后被全部吸收或部分吸收。

如果考虑为部分吸收,未被吸收的部分在经过内部碰撞、发散后会在略远的地方重新离开表面。一般情况下不考虑此部分,而次表面散射会考虑,使得诸如皮肤、蜡质等材质更为真实。

在通常情况下,根据能量守恒,我们只是单纯地用1减去镜面反射分量,就得到了漫反射分量。

然而,对于金属(Metallic)表面,其所有折射光均会被吸收。因此,在PBR管线中,金属表面不存在漫反射光,只存在镜面反射光。

反射光与折射光它们二者之间是互斥的关系。无论何种光线,其被材质表面所反射的能量将无法再被材质吸收。

float kS = calculateSpecularComponent(...); // 反射/镜面 部分
float kD = 1.0 - ks;                        // 折射/漫反射 部分

反射率方程

L_o(p,\omega_o) = \int_\Omega f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n\ ·\omega_id\omega_i

已知的:

  • \(p\):入射点坐标
  • \(\omega_o\):出射方向
  • \(\omega_i\):入射方向
  • \(n\):法向量

未知的:

  • 辐射率(Radiance)\(L\)表示,代表来自单一方向上的光线强度。亦可理解为一个拥有辐射通量\(\Phi\)的光源在单位面积\(A\),单位立体角\(\omega\)上辐射的总能量。
L = \frac{d^2\Phi}{dAd\omega\cos\theta}

在这里,我们不把光源视为点,而是平面,所以光源有单位面积的概念。

image-20241224134419511

辐射率受到入射光线与平面法线夹角\(\theta\)的余弦值\(\cos\theta\)dot(lightDir, N的影响。光线垂直于平面时,强度最高。因此,反射率方程中的\(n\ · \omega_i\)就代表着影响辐射率的参数。

辐射率本身,在黎曼和代码中可以从光源处获得。也可以通过环境贴图测算所有入射方向上的辐射率。在代码中,辐射率用一个函数L表示

当我们把立体角和面积视为无限小,此时辐射率表示单束光线穿过空间中一个点的通量。此时,我们可以计算出作用于单个点(即片段)上的单束光线的辐射率。

这种情况下,立体角变为方向向量,面A变为点P。由此,我们可以在着色器中使用辐射率计算单束光线对每个片段的作用了。

  • 辐射通量(Radiant Flux)\(\Phi\)表示,代表一个光源输出的能量,单位瓦特

  • 辐射强度(Radiant Intensity)\(I\)表示,代表单位球面上,一个光源向每单位立体角所投送的辐射通量

I = \frac{d\Phi}{d\omega}

立体角为投射到单位球体上的一个截面的面积。可以将其理解为带有体积的方向。

image-20241224133553743

简单理解:一束光,其通过\(\omega_i\)方向入射到点p的能量大小可以以\(n ·\omega_i\)表示。入射后,反射光向四面八方反射出去。此时,点p的观察者向\(\omega_o\)方向看去,发现\(\omega_o\)方向的辐照度就是反射率方程右侧式子未经积分的结果。

辐照度(Irradiance)指所有投射到点p上的光线总和。

然而,实际情况下肯定不止一束光线投射到点p,入射光应当也是来自四面八方的。因此,我们需要统计来自于以点p为球心的半球领域(Hemisphere)内所有方向上的入射光。这个半球领域以\(\Omega\)表示。

image-20241224141410017

我们将单束光线计算得到的辐照度,对半球领域内的所有入射方向进行积分,得到的就是真正的辐照度。

反射率方程没有解析解,所以要通过离散的方式,按一定的步长将其分散求解。然后根据步长大小将结果平均化。该方法被称为黎曼和(Reimann Sum)。

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

BRDF

双向反射分布函数(Bidirectional Reflective Distribution Function, BRDF)用于基于表面材质属性对入射辐照率进行加权。

可以注意到,入射辐照率有两个加权参数,BRDF和入射方向与法向量夹角余弦值。理解了这个以后反射率方程就更好理解了。

BRDF接受入射方向\(\omega_i\),出射方向\(\omega_o\),平面法线\(n\)和粗糙度\(\alpha\)作为输入。它可以近似求出每束光线对一个给定了材质属性的平面上最终反射出的光线的贡献程度。

例如,如果一个平面绝对光滑,那么除入射光线输出1.0外,其余光线全部输出0.0。

在现代实时渲染管线中,我们一般使用Cook-Torrance BRDF模型。它兼有漫反射和镜面反射两部分:

f_r = k_df_{lambert}+f_{cook_torrance}

此处:

  • \(k_d\)表示入射光线中被折射部分能量的比率
  • \(k_s\)表示被反射部分的比率
  • \(f_{lambert}\)表示漫反射部分,\(f_{lambert} = \frac{c}{\pi}\),其中\(c\)为表面颜色
  • \(f_{cook-torrance}\)表示镜面反射部分。如下:
f_{cook-torrance=\frac{DFG}{4(\omega_o·n)(\omega_i·n)}}

其中,DFG为三个函数,分别为:

  • 法线分布函数(Normal Distribution Function):估算在给定粗糙度的条件下,朝向方向与半程向量一致的微平面数量。是主要受粗糙度影响的值。
  • 几何函数(Geometry Function):当一个平面比较粗糙时,其上的部分微表面可能挡住其余微表面,从而减少表面反射的光线,形成阴影。这类阴影被称为“自成阴影”
  • 菲涅尔方程(Fresnel Equation):描述不同表面角下表面反射光线的比率。

例如,一个不算光滑的表面,当视线与其逐渐平行时,镜面反射的强度逐渐增大。

法线分布函数

该函数(NDF)从统计学上近似表示与半程向量取向一致的微平面比率。其返回的值越小,则说明在此范围内与半程向量取向一致的微平面越少,该范围就越暗。

常用的NDF为Trowbridge-Reitz GGX。其GLSL实现如下:

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;
}
NDF_{GGXTR}(n,h,\alpha) = \frac{\alpha^2}{\pi((\alpha^2-1)(n · h)^2+1)^2}

其中,H为半程向量,α为粗糙度。

几何函数

该函数从统计学近似求得微平面自成阴影的比率。其接受粗糙度作为输入参数,粗糙度越高,自成阴影概率更高。

G_{SchlickGGX}(n,v,k) = \frac{n · v}{(n·v)(1-k)+k}

其中,k为α的重映射。根据情况不同,二者关系也不同。

针对直接光照:\(k_{direct} = \frac{(\alpha+1)^2}{8}\)

针对IBL光照:\(k_{IBL}=\frac{\alpha^2}{2}\)

对于几何部分,有一部分的遮蔽是由视线本身无法到达被遮蔽的微表面导致(几何遮蔽,Geometry Obstruction),也有一部分由微表面被自成阴影纳入范围导致(几何阴影,Geometry Shadowing)。

为了将上面两个部分都纳入公式考虑范围,我们可以采用Smith’s method来计算几何函数:

G(n,v,l,k) = G_{sub}(n,v,k)G_{sub}(n,l,k)

其中,\(G_{sub}\)就是指SchlickGGX法。

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);
}

菲涅尔方程

此方程描述被反射光线对比被折射光线的比率,其返回值随观察角度不同而不同。

垂直观察时,任何材质表面都有基础反射率(Base Reflectivity)。但如果以近乎90度的角度观察表面,反光就明显的多。

我们使用Fresnel-Schlick近似法求取菲涅尔方程近似解:

F_{Schlick}(h,v,F_0)=F_0+(1-F_0)(1-(h·v))^5

其中,\(F_0\)表示平面基础反射率,由折射指数(Indices of Refraction, IOR)计算得出。

该近似法仅对非金属表面有定义。对于金属,需要使用不同结构的菲涅尔,这十分不方便。因此,我们与计算出\(F_0\)(即垂直观察时的基础反射率)结果,然后根据观察角进行插值,就可以对任何材质使用同一公式了。

\(F_0\)以RGB三原色表示,因为金属材质表面的反射光有时带有色彩。所以我们可以看到,非金属表面的F0三个分量相等,而金属则不相等。

金属度(Metalness)参数用于描述一个材质表面是金属还是非金属的。通过控制金属度,可以细微调整材质表面的视觉效果。

Fresnel-Schlick近似的GLSL实现如下:

vec3 F0 = vec(0.04); // 0.04为常见电介质的基础反射率平均值
F0 = mix(F0, surfaceColor,rgb, metalness); // 金属度越大,基础反射率越接近表面颜色。

vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    // 其中cosTheta为h dot v
    return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}

Cook-Torrance反射率方程

L_o(p,\omega_o) = \int_\Omega(K_d\frac{c}{\pi}+k_s\frac{DFG}{4(\omega_o·n)(\omega_i·n)}L_i(p,\omega_i)d\omega_i)

PBR材质

一个典型的PBR材质应当包含下列贴图:

  • 反照率(Albedo):指定金属材质表面颜色(即基础反照率F0),类似于漫反射纹理(仅包含表面颜色)。
  • 法线:法线贴图,用于制造凹凸不平的视觉效果。
  • 金属度:逐纹素指定金属质地。
  • 粗糙度:逐纹素指定材质粗糙程度。有时基于One Minus变为光滑度(Smoothness)贴图。
  • AO:环境光遮蔽贴图。

光照

点光源在每个方向的辐射通量均相同。因此,在不考虑衰减的情况下,其辐射通量可用一个三维向量表示。

然而,真正的辐射率计算必定需要考虑衰减,而辐射率\(L(p,\omega)\)也确实需要接受一个位置\(p\)作为输入。

由此,我们得到以下过程:

对于直接点光源,辐射率函数L进行以下操作:

  1. 获取光源颜色值
  2. 将颜色值按光源和某点p的距离衰减
  3. 按照视线与法线夹角余弦值,即\(n·\omega_i\)缩放。其中\(\omega_i\)就是光源和\(p\)点之间的方向向量。

代码如下:

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;

对于其他光源,平行光不需要计算衰减;聚光灯的辐射强度需要根据聚光灯方向向量进行缩放。

反射率方程中,计算总辐射率需要对点p所在的半球领域进行积分。但实际上,程序中的光源数量是有限的。我们只需要迭代光源数量的次数,便可以计算出所有光源对某点的辐射率贡献,直接相加就能得到总辐射率。

考虑间接光源就需要用到IBL和积分计算了。

PBR着色器示例

片段着色器

#version 330 core
// I/O相关
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

// 相机相关
uniform vec3 camPos;

// 材质属性相关
uniform sampler2D albedoMap;
uniform sampler2D metallicMap;
uniform sampler2D roughnessMap;
uniform sampler2D aoMap;

// 光照相关
const int LIGHT_COUNT = 4;
uniform vec3 lightPositions[LIGHT_COUNT];
uniform vec3 lightColors[LIGHT_COUNT];

// 辅助常量
const float PI = 3.14159265359;

// 函数签名
vec3 fresnelSchlick(float cosTheta, vec3 F0);
float DistributionGGX(vec3 N, vec3 H, float roughness);
float GeometrySchlickGGX(float NdotV, float roughtness);
float GeometrySmithGGX(vec3 N, vec3 V, vec3 L, float roughness);

void main(){
    vec3 albedo     = pow(texture(albedoMap, TexCoords).rgb, 2.2); // 漫反射贴图通常为Gamma空间下制作,需要先转换到线性
    float metallic  = texture(metallicMap, TexCoords).r;
    float roughness = texture(roughnessMap, TexCoords).r; // 金属度和粗糙度贴图一般位于线性空间
    float ao        = texture(aoMap, TexCoords).r; // 一般而言AO贴图也需要转换到线性,这里图方便没转

    vec3 N = normalize(Normal); // 法线
    vec3 V = normalize(camPos - WorldPos); // 视线方向,即ViewDir
    // -----直接光照部分-----
    vec3 Lo = vec3(0.0); // 最终计算结果,即总辐射率
    for(int i=0; i<LIGHT_COUNT; ++i){
        // 计算Li项
        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; // 单个光源在入射方向的辐射率
        // 计算BRDF项
        // 1. 菲涅尔
        vec3 F0 = vec3(0.04);
        F0 = mix(F0, albedo, metallic);
        vec3 F = fresnelSchlick(max(dot(H, V),0.0), F0);
        // 2. 法线分布
        float NDF = DistributionGGX(N, H, roughness);
        // 3. 几何
        float G = GeometrySmith(N, V, L, roughness);
        // 4. Cook-Torrance高光项
        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;
        // 5. Cook-Torrance漫反射项
        vec3 diffuse = albedo / PI;
        // 6. 总结
        vec3 kS = F; // 菲涅尔方程表示反射光线在所有光线内的占比
        vec3 KD = 1.0 - kS; // 所谓漫反射,就是折射光线在表面内部多次弹射后再次(在入射点附近)弹出表面的光,根据能量守恒可以求得
        kD *= 1.0 - metallic; // 金属不存在折射,只有反射
        float NdotL = max(dot(N, L), 0.0);
        Lo += (kD * diffuse + specular) * radiance * NdotL;
    }
    // 计算环境光照
    vec3 ambient = vec3(0.03) * albedo * ao; // AO(环境光遮蔽)作用于环境光
    vec3 color = ambient + Lo;
    // Reinhard色调映射,从LDR映射到HDR
    color = color / (color + vec3(1.0));
    // Gamma矫正
    color = pow(color, vec3(1.0 / 2.2));
}

// -----BRDF部分-----
// ---菲涅尔方程---
// 计算菲涅尔方程的Schlick近似
// cosTheta一般为H dot V,表示法线和视线的夹角。夹角越小,此值越大,菲涅尔项越弱
// F0为垂直视角反射率,材质独有属性,一般绝缘体为0.04,金属需要根据情况插值。则有F0 = mix(0.04, albedo, metallic)
vec3 fresnelSchlick(float cosTheta, vec3 F0){
    // clamp用于将第一参数的值超出第二-第三参数区间的部分设置为边缘值,此处用于防止亮点或暗点
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
// ---法线分布函数---
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 nom = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;
    return nom / denom;
}
// ---几何函数---
float GeometrySchlickGGX(float NdotV, float roughtness){
    float r = (roughness + 1.0);
    float k = (r*r) / 8.0; // 此处未用IBL,否则k计算方式将有所不同
    float nom = NdotV;
    float denom = NdotV * (1.0 - k) + k;
    return nom / denom;
}
float GeometrySmithGGX(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, roughtness);
    return ggx1 * ggx2;
}

注意:

  • 此着色器中,我们在线性空间计算光照。
  • 线性空间钟,逆平方衰减量更加物理准确。
  • Cook-Torrance BRDF的镜面反射分量系数就是菲涅尔项,它代表反射光线占总光线的比例。因此,最终计算时无需重复乘以kS。
  • 由于PBR基于物理,所以所有光照输入都与真实的物理值相仿,很容易使得最终计算的Lo超过1.0而被截断,所以需要将颜色值进行色调映射到HDR范围
  • PBR要求所有数值均处于线性空间,所以要在颜色计算完毕后进行Gamma矫正计算。

IBL

漫反射辐照度

基于图像的光照(Image based lighting, IBL)将环境立方体贴图上的每个像素视为光源,并在渲染方程中直接使用这些光源。相比于直接光源,IBL能捕捉近乎全部的环境光照,是全局光照的粗略近似。因此,在PBR中使用IBL能大大提高光照真实度。

前文的反射率方程:

L_o(p,\omega_o) = \int_\Omega(k_d\frac{c}{\pi}+k_s\frac{DFG}{4(\omega_o·n)(\omega_i·n)})L_i(p,\omega_i)n·\omega_id\omega_i

我们的目标是计算半球\(\Omega\)上所有入射光方向\(\omega_i\)的积分。在使用直接光照时,因为仅存在N个直接光源,所以对于着色点\(p\),只存在N个入射方向,可以把积分化为N次循环。

然而,使用IBL时,来自周围环境的每个方向的入射光都有可能具有辐射度。因此,我们必须实现下面两个需求:

  • 给定任何入射向量\(\omega_i\),都可以获取来自该方向上场景的辐射度。
  • 解决积分的方法必须快速、实时。

对于要求1,我们可以将环境贴图作为立方体贴图进行采样,即vec3 radiance = texture(_cubemapEnvironment, w_i).rgb

对于要求2,我们可以预先计算好所有需要的数值,将它们存储在纹理中,需要时进行采样即可。

我们知道,反射率方程的积分内多项式由漫反射项和镜面反射项构成。将二者拆开:

L_o(p,\omega_o) = \int_\Omega(k_d\frac{c}{\pi})L_i(p,\omega_i)n·\omega_id\omega_i + \int_\Omega(k_s\frac{DFG}{4(\omega_o·n)(\omega_i·n)})L_i(p,\omega_i)n·\omega_id\omega_i

对于漫反射项,我们知道\(k_d、c、\pi\)都是常数项。所以可以将其移出积分,得到新BRDF漫反射项\(k_d\frac{c}{\pi}\int_\Omega L_i(p,\omega_i)n·\omega_id\omega_i\)

接下来我们着重来看漫反射部分。

从上面的漫反射项可以看出,该积分只依赖于入射方向。因此,我们进行的预处理就是:给定一个立方体贴图,计算其在每个采样方向,即纹素上的漫反射积分结果。该预处理通过卷积实现。

卷积的特性是,对数据集中的一个条目做一些计算时,要考虑到数据集中的所有其他条目。这里的数据集就是场景的辐射度或环境贴图。因此,要对立方体贴图中的每个采样方向做计算,我们都会考虑半球Ω上的所有其他采样方向。

然而,半球内存在的方向实在太多,如果一个个考虑必将消耗非常多的性能。因此,我们对半球上的方向进行离散采样,并对其辐射度取平均值,来计算每个输出采样方向的积分。

卷积后,我们得到的输出采样方向积分究竟是什么?

预计算的立方体贴图,在每个采样方向\(\omega_o\)上存储其积分结果,可以理解为场景中所有能够击中以\(\omega_o\)为法线的表面的间接漫反射光的预计算总和。是对全局光照的近似。辐照度指投射到某点的所有光线的总和。所以完成积分计算后的贴图才叫辐照度贴图。

完成积分后,我们会得到一张新的立方体贴图。它有点类似于对原本的立方体环境贴图进行模糊卷积处理。这张新贴图被称为辐照度贴图。通过该贴图,给定一个入射方向,我们可以直接采样到该方向的预计算辐照度。

然而,这种方式默认材质表面始终处于立方体贴图的中心,也就是说我们默认立方体贴图是作为天空盒的。如果一个场景很复杂时,就会显得不太真实(例如,室内的镜面反射和室外的应当不同,但在我们的方法里却相同)。

为了解决这一问题,可以使用反射探针技术。

环境立方体贴图和其辐照度贴图

左侧是环境立方体贴图,可以被认为是辐射率贴图;右侧为辐照度贴图

.hdr

先前我们提到,PBR工作流中的一切数值都不应当被局限在LDR范围内。因此,直接点光源的颜色也必定会大于一。在IBL中,类似地,我们通过辐照度贴图采样到的值也必定在HDR范围而非LDR范围内。也就是说,我们的环境立方体贴图必须为HDR贴图

辐射率贴图又被称为辐射度文件,扩展名为.hdr。该文件存储了一张完整的环境辐射度贴图(以等距柱状投影贴图的形式,而非六面立方体贴图),六个面的颜色数据均为浮点数,允许0-1范围外的值。它使用8 bit存储每个通道,并用alpha通道存放指数。

使用stb_image库可以方便地将.hdr文件加载为浮点数组。如下:

#include "stb_image.h"
// ...
stbi_set_flip_vertically_on_load(true); // 由于OpenGL的默认纹理倒转,需要翻转预处理
int width, height, nrComponents; // 长、宽、通道
float* data = stbi_load("name.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_TEXTRUE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTRUE_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;
}

上述代码执行后,将自动把.hdr文件存储到浮点数组中,保持HDR范围。

随后,我们尝试将等距圆柱投影贴图转换为立方体贴图,以便更方便地采样。

// 顶点着色器,原样渲染立方体
#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);
}
// 片段着色器
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform sampler2D equirectangularMap; // 等距柱状纹理贴图
const vec3 invAtan = vec2(0.1591, 0.3183);
// 通过变换UV,使得采样方向转换到等距柱状空间
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));
    vec3 color = texture(equirectangularMap, uv).rgb;
    FragColor = vec4(color, 1.0);
}

我们知道,在片段着色器运行时,不处于[0,1]范围内的uv,以及我未通过深度测试的片段将被剔除。也就是说,只有我们在视口内看到的东西才能被渲染。

我们通过SampleSphericalMap变换UV,也只是对视口内存在的UV进行变换。然而,要生成立方体贴图的六个面,我们就必定需要把六个面都渲染到纹理,然后将六个纹理合成为立方体贴图。

当然,我们也可以通过预计算将.hdr转换为cubemap,例如使用Blender的烘焙等。

随后,我们对同一立方体渲染六次,每次面对立方体的一个面,用FBO记录结果:

// 创建FBO、RBO
unsigned int captureFBO, captureRBO;
glGenFrameBuffers(1, &captureFBO);
glGenRenderBuffers(1, &captureRBO); // 帧缓冲需要绑定附件才能运作,RBO用于深度缓冲,纹理附件用于颜色缓冲
glBindFrameBuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderBuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512); // 尺寸为512*512,代表cubemap一个面的分辨率
glFramebufferRenderBuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO);  // 将RBO附加到FBO
// 生成立方体贴图
unsigned int envCubemap;
glGenTextures(1, &envCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
for(unsigned int i=0;i<6;i++){
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X+i, 0, GL_RGB16F, 512, 512, 0, GL_RGB, GL_FLOAT, nullptr); //为cubemap的每个面分配内存,故最后参数为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纹理捕捉到立方体贴图面上。

// FOV90度,分别LookAt六个面
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))
};
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap",0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glViewport(0,0,512,512); //与纹理附件、RBO分辨率对应
glBindFrameBuffer(GL_FRAMEBUFFER, captureFBO);
// 对于每个面:
for(unsigned int i=0;i<6;i++){
    equirectangularToCubemapShader.setMat4("view",captureViews[i]);
    // 将已绑定CUBEMAP(此时六个面分配了内存但没有数据)的每个面作为纹理附件,从而渲染到纹理
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTJ_BUFFER_BIT);
    // 此时渲染的立方体面将被绘制到CUBEMAP的某个面上
    renderCube();
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

.随后,使用天空盒着色器渲染完成的Cubemap:

// 天空盒顶点着色器
#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)); //将平移变换移除
    vec4 clipPos = projection * rotView * vec4(localPos, 1.0);
    gl_Position = clipPos.xyww; //确保深度值始终为1
}
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap;
void main(){
    vec3 envColor = texture(environmentMap, localPos).rgb;
    // HDR色调映射,转换到LDR空间
    envColor = envColor / (envColor + vec3(1.0));
    // .hdr为线性空间,需要进行伽马矫正
    envColor = pow(envColor, vec3(1.0/2.2));
    FragColor = vec4(envColor, 1.0);
}

Cubemap卷积

我们先前提到,对辐射度贴图(即环境立方体贴图或.hdr文件)进行积分,会得到辐照度贴图。对辐照度贴图使用一个方向向量采样,得到的值就是以该方向向量为法线的材质表面接收到的总光线强度的近似。

那么,这个卷积操作具体该如何实现呢?

首先要明确,均匀采样辐射度并离散计算辐照度是可行的,但它的性能消耗依然比较大。所以我们才要对辐射度贴图进行与计算。

与SSAO类似,我们随机均匀采样的范围为一个半球。为了生成辐照度贴图,我们需要将环境光照求卷积,转换为立方体贴图。假设对于每个片段,表面的半球朝向法向量 N ,对立方体贴图进行卷积等于计算朝向 N的半球 Ω 中每个方向 wi 的总平均辐射率。

半球到底是什么?

我们知道,.hdr文件存储的辐射度贴图以等距柱状投影形式呈现。等距柱状投影和立方体贴图都是对球面贴图投影,用于把球面展开为单张贴图或六张贴图。

因此,真正的辐射度贴图应当是球面的形式。

其次,我们知道每个半球都有一个朝向向量。把辐射度贴图球面一切为二,使得其中一个半球的朝向向量为wi。我们便得到了wi所代表的半球。

具体卷积步骤为:

  1. 对于立方体贴图的每个纹素,在纹素所代表的方向(可以采样到该纹素的方向向量的反方向)的半球\(\Omega\)内生成固定数量的采样向量,然后让这些向量对原立方体贴图进行采样。
  2. 对采样的结果取平均值,赋值给新立方体贴图的同一纹素位置。

观察BRDF漫反射分量:

\(k_d\frac{c}{\pi}\int_\Omega L_i(p,\omega_i)n·\omega_id\omega_i\)

该积分对立体角进行积分。而立体角极难处理,所以我们用球坐标代替。

image-20241231121744834

对于围绕大圆的Head角\(\phi\),采样范围为\([0,2\pi]\)。对于从半球顶点出发的Pitch角\(\theta\),采样范围为\([0,\frac{1}{2}\pi]\)

反射积分方程的漫反射项更新为下:

L_o(p,\phi_o,\theta_o)=k_d\frac{c}{\pi}\int_{\phi=0}^{2\pi}\int_{\theta=0}^{\frac{1}{2}\pi}L_i(p,\phi_i,\theta_i)cos(\theta)sin(\theta)d\phi d\theta

为了求解积分,我们需要在半球内采集固定数量的离散样本并对结果取平均值。分别给每个球坐标轴指定样本数量\(n1、n2\)以求积分黎曼和。上述积分式转化为:

L_o(p,\phi_o,\theta_o) = k_d\frac{c}{\pi}\frac{1}{n1n2}\sum_{\phi=0}^{n1}\sum_{\theta=0}^{n2}L_i(p,\phi_i,\theta_i)cos(\theta)sin(\theta)d\phi d\theta

当我们离散地对两个球坐标轴采样时,每个采样近似代表了半球上的一小块区域。

下面的片段着色器对已转换为立方体贴图的.hdr文件进行卷积,从而得到辐照度立方体贴图。

#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap; // 转换为为立方体贴图的.hdr文件
const float PI = 3.14159265359;
void main(){
    vec3 normal = normalize(localPos);
    vec3 irradiance = vec3(0.0);
    //---卷积操作---
    // 构建以法线为中心的局部坐标系,类似于切线空间,便于采样
    vec3 up = vec3(0.0,1.0,0.0);
    vec3 right = cross(up, normal);
    up = cross(normal, 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){
            // 此为采样点在局部坐标系(即球面坐标)的坐标
            vec3 tangentSample = vec3(sin(theta)*cos(phi),sin(theta)*sin(phi),cos(theta));
            // 转换到世界坐标系
            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));
    FragColor = vec4(irradiance, 1.0);
}

在辐射度立方体贴图生成后,便可以调用此着色器生成辐照度立方体贴图了。配套的C++代码如下:

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);
}

辐照度图不需要太高的分辨率,因为它的高频细节较少。这里用32*32。

需要注意的是,这里我们不使用先前预计算的辐射度贴图,而是重新将立方体渲染六次并渲染到CubeMap纹理,再用卷积着色器对其操作。

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32,32);
irradianceShader.use();
irradianceShader.setInt("environmentMap",0);
irradianceShader.setMat4("projection",captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glViewport(0,0,32,32);
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);

应用到PBR

辐照度贴图表示所有周围间接光累计的反射率的漫反射部分的积分。先前的PBR代码中,我们设置vec3 ambient = vec3(0.03),现在我们要用辐照度贴图的采样值代替:

vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kD = 1.0 - kS;
// 此处N就是法线
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse    = irradiance * albedo;
vec3 ambient    = (kD * diffuse) * ao; 

我们希望粗糙度高的表面菲涅尔反射弱。然而现在的方法并没有把菲涅尔项与粗糙度结合。通过改进Schlick菲涅尔近似,可以解决这一问题:

vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness){
    return F0 + (max(vec3(1.0-roughness),F0)-F0)*pow(1.0-cosTheta,5.0);
}

最终的环境光代码为:

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;

镜面反射IBL

理论

反射方程的镜面反射项如下:

\int_\Omega(k_s\frac{DFG}{4(\omega_o·n)(\omega_i·n)})L_i(p,\omega_i)n·\omega_id\omega_i = \int_\Omega f_r(p,\omega_i,\omega_o)L_i(p,\omega_i)n·\omega_id\omega_i

对于镜面反射项,其中\(DFG\)三项均与视线方向\(v\)有关。因此,如果要进行穷举遍历,存在的可能性将极其庞大,无法实时计算。为了解决这一问题,我们可以预先计算镜面部分的卷积,在后续着色时采样即可。该方案被称为分割求和近似法

在分割求和近似法中,我们将方程的镜面项分割为两个独立部分,然后对其分别求卷积,并在着色器中求和,得到真正的镜面辐照度量。

我们将镜面反射项如此拆分:

L_o(p,\omega_o)=\int_\Omega L_i(p,\omega_i)d\omega_i\ *\ \int_\Omega f_r(p,\omega_i,\omega_o)n·\omega_id\omega_i

因为有两个部分,所以我们要对这两个部分分别进行卷积,生成贴图。

第一部分为预滤波环境贴图(PrefilterMap),它是积分中与漫反射项共用的一个系数。它类似于辐照度图,是考虑了粗糙度的环境卷积贴图。之所以考虑粗糙度,是因为随着粗糙度的增加,镜面反射会更模糊。

而粗糙度越大,贴图越模糊,需要的精度就越小。因此,高粗糙度的预滤波环境贴图的分辨率可以变得较小。因此,我们可以把不同级别的粗糙度指的预卷积结果存储在Mipmap中。如图:

image-20250102145713279

第二部分为BRDF积分贴图(BRDFLUT),它是一张查找贴图(Look Up Texture, LUT),存储BRDF对每个粗糙度和入射角的响应结果。贴图的横坐标是BRDF输入,粗糙度为纵坐标;R通道存储菲涅尔响应系数,G通道存储菲涅尔响应偏差值。

image-20250103141657325

通过结合采样预滤波环境贴图和BRDFLUT我们就可以获得镜面积分的结果:

float lod = getMipLevelFromRoughness(roughness); // 根据粗糙度,获取指定的预滤波环境贴图Mipmap层级
vec3 prefilteredColor = textureCubeLod(PrefilteredEnvMap, refVec, lod); // 从指定的Mipmap层级中取出对应粗糙度的预滤波环境贴图
vec2 envBRDF = texture2D(BRDFIntegrationMap, vec2(NdotV, roughness)).xy; // 使用(NdotV, roughness)作为UV,采样BRDFLUT,获取BRDF项的数值
vec3 indirectSpecular = prefilteredColor * (F * envBRDF.x + envBRDF.y); // 得到间接光照的镜面反射项数值。其中,envBRDF的R通道,即.x,为菲涅尔响应系数,应当与材质基础反射率F相乘;G通道,即.y,为菲涅尔响应偏差值

操作

预滤波HDR环境贴图

先前我们已经明确,预滤波环境贴图类似于漫反射IBL中的辐照度图,但引入了粗糙度作为变量(粗糙度越大,“辐照度图”就越“模糊”),需要根据不同的粗糙度生成不同精度/模糊程度的辐照度图,并存储在Mipmap中。

为了生成Mipmap,我们首先需要在为预滤波环境立方体贴图分配内存后,通过glGenerateMipmap为其Mipmap分配内存:

unsigned int prefilterMap;
glGenTexture(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); // 为Mipmap分配内存

漫反射IBL中,我们使用标准半球均匀采样入射向量,因为对于漫反射,其入射向量的方向均匀且随机。然而,对于镜面反射来说,根据粗糙度不同,入射向量的分布也有所不同。如下:

image-20250106123555674

可能出射的反射光构成的形状被称为镜面波瓣(Specular Lobe)。随着粗糙度增加,镜面波瓣的大小增加;随着入射方向改变,镜面波瓣的形状也会发生变化。我们在卷积进行采样时,应当选取镜面波瓣内的向量,此过程被称为重要性采样。

镜面波瓣始终指向微表面的半程向量。

蒙特卡洛积分

蒙特卡洛积分建立在大数定律的基础上,不为近乎无限的样本值x求积分,而是简单地从总体中随机挑选样本N生成采样值并求平均。样本数N越大,结果越接近积分的精确结果。公式表示:

O = \int^b_af(x)dx=\frac{1}{N}\sum^{N-1}_{i=0}\frac{f(x)}{pdf(x)}

我们从[a,b]范围内采样N个样本,将它们相加并除样本数以取平均。公式中,\(pdf\)概率密度函数,表示特定样本在整个样本集上发生的概率。

当每次取到的样本均服从pdf(x),则此蒙特卡洛估算无偏(Unbias),即,随着样本数的增加,蒙特卡洛积分结果必定逐渐收敛到精确解。

当生成的样本并不完全服从pdf(x),而是有特定的倾向,则此蒙特卡洛估算有偏。有偏蒙特卡洛积分有更快的收敛速度,对性能敏感的应用程序来说十分合适。只要偏差的倾向较为合理,最终收敛的结果也不会有太多偏差。

使用低差异序列(Low-Discrepancy Sequence)进行蒙特卡洛积分可以进一步提升收敛速度。低差异序列生成的随机样本相较于完全随机样本,具有更加均匀的分布。此过程被称为拟蒙特卡洛积分(Quasi-Monte Carlo)

image-20250106130826690

总结:蒙特卡洛积分时一种以高效的离散方式求积分的直观方法。为了进一步提升蒙特卡洛积分的计算速度,我们采用下面两种方法:1. 使用有偏蒙特卡洛积分,将采样范围按照实际情况进行限制。2. 使用低差异序列生成采样样本。

在镜面IBL引入蒙特卡洛的关键在于,借助有偏蒙特卡洛的范围限制,将材质表面的反射特性纳入采样考虑范围内,即重要性采样。

我们接下来将使用重要性采样来预计算间接镜面反射项。

低差异序列

我们将使用Hammersley序列作为拟蒙特卡洛过程的低差异序列。该序列通过把十进制数字的二进制表示镜像翻转到小数点右边得到。代码如下:

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
}
// N为总样本数,i为样本索引
vec2 Hammersley(uint i, uint N)
{
    return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}  
GGX重要性采样

首先,生成随机低差异序列:

const uint SAMPLE_COUNT = 4096u;
for(uint i = 0u; i<SAMPLE_COUNTL; i++){
    vec2 Xi = Hammersley(i, SAMPLE_COUNT);
}

随后,进行重要性采样。先对采样向量进行偏移,使其朝向特定粗糙度的镜面波瓣方向。

vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness) {
    // 经验公式,平方粗糙度视觉效果更佳
    float a = roughness * roughness;
    // 生成半程向量 H 的方位角 phi。
    // Xi.x 用于随机生成 phi 的均匀分布。
    float phi = 2.0 * PI * Xi.x;
    // 根据 GGX 分布公式生成极角的余弦值 cosTheta。
    // 使用 GGX 分布的逆变换采样公式:
    // cosTheta = sqrt((1 - Xi.y) / (1 + (a² - 1) * Xi.y))
    // Xi.y 为均匀分布的随机数,用于控制分布方向。
    float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a * a - 1.0) * Xi.y));
    // 计算 sinTheta(极角的正弦值)。
    // 用于后续将球面坐标(phi, theta)转换为笛卡尔坐标。
    float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
    // 创建半程向量 H(微表面法线)的局部空间坐标。
    // 使用球面坐标公式将 (phi, cosTheta, sinTheta) 转换为 3D 笛卡尔坐标。
    vec3 H;
    H.x = cos(phi) * sinTheta; // x 分量
    H.y = sin(phi) * sinTheta; // y 分量
    H.z = cosTheta;            // z 分量(指向半球上的点)
    // 确定一个基向量 up,与法线 N 足够正交。
    // 如果 N 接近 z 轴(垂直向上),选择 x 轴作为 up;否则选择 z 轴。
    vec3 up = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    // 计算切线空间的两个正交基向量:tangent 和 bitangent。
    // tangent 是 N 和 up 的叉积,确保与 N 和 up 都正交。
    vec3 tangent = normalize(cross(up, N));      // 切线向量
    vec3 bitangent = cross(N, tangent);          // 副切线向量
    // 将半程向量 H 从局部空间转换到世界空间。
    // 将 H 的坐标(局部坐标系下)组合成一个世界坐标向量 sampleVec。
    vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
    // 返回归一化后的采样向量 sampleVec。
    // 采样向量是在 GGX 分布下,基于法线 N 和粗糙度生成的半程向量。
    return normalize(sampleVec);
}

在镜面IBL中,微表面法线与半程向量H的朝向一直。因为镜面反射定律要求入射光方向和反射光方向对称于微表面法线,而半程向量的方向正好满足这种对称性。

通过调用该函数,我们便可以获得一个采样向量,该向量大体围绕着预估的微表面半程向量(法线)。最终的计算预滤波环境贴图的着色器如下:

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

uniform samplerCube environmnetMap;
uniform float roughness;

const float PI = 3.141591265359;

float RadicalInverse_Vdc(uint bits);
vec2 Hammersley(uint i, uint N);
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness);

void main(){
    // 天空盒永远位于世界原点,故其世界坐标等效于局部坐标
    // localPos归一化后得到从原点指向该片段的单位向量
    // 天空盒本质上可以看作球体,指向片段的向量就是该片段的法线
    vec3 N = normalize(localPos);
    // 由于我们在卷积环境贴图时事先不知道视角方向,因此假设视角方向——也就是镜面反射方向——总是等于输出采样方向,尽管该情况掠射镜面反射效果不完美,但也足够
    vec3 R = N;
    vec3 V = N;

    const uint SAMPLE_COUNT = 1024u; // 采样样本数N
    float totalWeight = 0.0; // 累加权重,表示光照的贡献值累加,用于最后取有权平均值
    vec3 prefilteredColor = vec3(0.0);
    // 开始循环采样
    for(uint i=0;i<SAMPLE_COUNT;i++){
        vec2 Xi = Hammersley(i, SAMPLE_COUNT); // 生成低差异序列
        vec3 H = ImportanceSampleGGX(Xi, N, roughness); // 通过重要性采样,得到微表面半程向量(法线)
        vec3 L = normalize(2.0 * dot(V, H) * H - V); // 使用反射公式推导,V相当于镜面反射方向,H相当于法线,此公式可以求出入射光方向L
        float NdotL = max(dot(N, L),0.0); //N dot L可以表示表面法线和光照方向的夹角大小。NdotL值越大,光照贡献越大。
        if(NdotL>0.0){
            prefilteredColor += texture(environmentMap, L).rgb * NdotL;
            totalWeight += NdotL;
        }
    }
    prefilteredColor = prefilteredColor / totalWeight;
    FragColor = vec4(prefilteredColor, 1.0);
}
捕获预过滤Mipmap级别

预滤波卷积着色器需要在不同的Mipmap级别上运行:

prefilterShader.use();
prefilterShader.setInt("environmentMap", 0);
prefilterShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindFrameBuffer(GL_FRAMEBUFFER, envCubemap);
unsigned int maxMipLevels = 5;
for(unsigned int mip = 0; mip<maxMipLevels; mip++){
    // 计算Mipmap尺寸
    unsigned int mipWidth = 128 * std::pow(0.5,mip);
    unsigned int mipHeight = 128 * std::pow(0.5,mip);
    // 深度缓冲用RBO
    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的最后一个参数可以指定要渲染的目标mip级别
        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);

通过glsl的textureLod函数,可以从指定Mip级别的纹理中采样。

预过滤卷积伪像

在使用上面的预过滤环境贴图时,会遇到下面两个问题:

  • 立方体贴图接缝

默认情况下,OpenGL不会在立方体贴图的面之间进行插值。在使用高粗糙度的预滤波环境贴图时,由于其分辨率较低,面之间的接缝会格外明显。

我们可以通过glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS)解决这一问题。

  • 亮点

镜面反射的光强度变化大,高频细节多,所以需要进行大量采样。尽管我们已经进行了数千次采样,但对某些级别的预滤波环境贴图Mipmap来说依然不够,导致明亮区域周边出现亮点:

image-20250106150230700

一种方案是提高样本数量,但会降低性能表现,治标不治本。

另一种方案是基于积分的PDF和粗糙度采样环境贴图的Mipmap。低分辨率的环境贴图,高频细节也少,采样突变的情况更少:

float D = DistributionGGX(NdotH, roughness); // 计算NDF
float pdf = (D * NdotH / (4.0 * HdotV)) +0.0001; // 计算PDF(当前采样点的概率密度),用于权重归一化
float resolution = 512.0; // 环境贴图分辨率
// 计算每个纹素在立方体贴图中的立体角
//  一个立方体有6个面,每个面包含resolution^2个纹素
//  立方体贴图(即球面)覆盖了4PI个立体角
float saTexel = 4.0*PI/(6.0*resolution*resolution); 
// 立体角的大小与点的分布密度,即PDF成反比。如果一个采样点的概率密度很高,那么它出现的概率更大,所占的立体角就更小
// 概率密度越高,点更集中,单个采样点在球面上占的立体角更小。反之亦然。
float saSample = 1.0/(float(SAMPLE_COUNT)*pdf+0.0001); // 每个采样点对应的立体角
// 根据每个采样点的立体角 saSample和每个像素的立体角 saTexel,计算两者的比例
// 如果二者一致,说明采样点与像素刚好一对一对应,那么就不需要对环境贴图进行缩放
// 否则,意味着一个采样点对应着多个纹素,需要进行缩放(模糊处理)
float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel); // 当前粗糙度对应的Mipmap层级

进行该操作的前提是开启环境贴图的三线性过滤,以及生成环境贴图的Mipmap:

glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);
预计算BRDF

回顾镜面部分的反射率方程:

L_o(p,\omega_o)=\int_\Omega L_i(p,\omega_i)d\omega_i*\int_\Omega f_r(p,\omega_i,\omega_O)n·\omega_id\omega_i

目前,我们以及完成了左半部分的计算,得到了入射辐射率的卷积——辐照度。因此,我们可以将左半部分视为1(即纯白的环境光,或辐射度恒定为1.0),进行右侧计算。

我们作如下化简:

\int_\Omega f_r(p,\omega_i,\omega_o)n·\omega_id\omega_i = \int_\Omega \frac{f_r(p,\omega_i,\omega_o)}{F(\omega_o,h)}F(\omega_o,h)n·\omega_id\omega_i

使用Fresnel-Schlick公式替换右侧的\(F\)得到

\int_\Omega\frac{f_r(p,\omega_i,\omega_o)}{F(\omega_o,h)}(F_0+(1-F_0)(1-\omega_o·h)^5)n·\omega_id\omega_i

使用\(\alpha\)替换\((1-\omega_i·h)^5\),得到:

\int_\Omega\frac{f_r(p,\omega_i,\omega_o)}{F(\omega_o,h)}(F_0+(1-F_0)\alpha)n·\omega_id\omega_i = \int_\Omega\frac{f_r(p,\omega_i,\omega_o)}{F(\omega_o,h)}(F_0*(1-\alpha)+\alpha)n·\omega_id\omega_i

拆分得:

\int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (F_0 * (1 - \alpha))  n \cdot \omega_i d\omega_i
              +
    \int\limits_{\Omega} \frac{f_r(p, \omega_i, \omega_o)}{F(\omega_o, h)} (\alpha)  n \cdot \omega_i d\omega_i

还原:

F_0 \int\limits_{\Omega} f_r(p, \omega_i, \omega_o)(1 - {(1 - \omega_o \cdot h)}^5)  n \cdot \omega_i d\omega_i
              +
    \int\limits_{\Omega} f_r(p, \omega_i, \omega_o) {(1 - \omega_o \cdot h)}^5  n \cdot \omega_i d\omega_i

注意,BRDF项中得F项与原本分母的F项进行了约分,后续的BRDF项,即f,不计算F项。

由此,我们可以对BRDF求卷积,以\(n\)\(\omega_o\)的夹角以及粗糙度作为输入,并将卷积结果存储在LUT中,得到BRDF积分贴图。

BRDF卷积着色器在2D平面上执行计算,使用其2D纹理坐标作为卷积输入。它根据BRDF几何函数和Fresnel-Schlick近似来处理采样向量。

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float a = roughness;
    float k = (a * a) / 2.0; // 在IBL中,k值不同

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

vec2 IntegrateBRDF(float NdotV, flaot roughness){
    vec3 V;
    V.x = sqrt(1.0 - NdotV*NdotV);
    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);
        vec3 H = ImportanceSampleGGX(Xi, N,, roughness);
        vec3 L = normalize(2.0 * dot(V,H)*H-V);
        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;
}
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);
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);  
完成IBL反射

PBR着色器关键部分如下:

#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
in vec3 WorldPos;
in vec3 Normal;

uniform vec3 albedo;
uniform float metallic;
uniform float roughness;
uniform float ao;

uniform samplerCube irradianceMap;
uniform samplerCube prefilterMap;
uniform sampler2D brdfLUT;

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 a = roughness*roughness;
    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;
}
// ----------------------------------------------------------------------------
float GeometrySchlickGGX(float NdotV, float roughness)
{
    float r = (roughness + 1.0);
    float k = (r*r) / 8.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;
}
// ----------------------------------------------------------------------------
vec3 fresnelSchlick(float cosTheta, vec3 F0)
{
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}
// ----------------------------------------------------------------------------
vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
    return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}   
// ----------------------------------------------------------------------------
void main()
{       
    vec3 N = Normal;
    vec3 V = normalize(camPos - WorldPos);
    vec3 R = reflect(-V, N); 

    // 结合金属度,计算基本反射率F0
    vec3 F0 = vec3(0.04); 
    F0 = mix(F0, albedo, metallic);

    // 计算直接光源的辐射率
    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; // 灯光在入射方向的辐射率

        // 计算BRDF
        float NDF = DistributionGGX(N, H, roughness); // 法线分布函数
        float G   = GeometrySmith(N, V, L, roughness); // 结合了几何遮蔽和几何阴影的几何函数。其中K值因为使用了IBL所以有所不同
        vec3 F    = fresnelSchlick(max(dot(H, V), 0.0), F0); // 菲涅尔方程

        vec3 numerator    = NDF * G * F;
        float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
        vec3 specular = numerator / denominator;

        // 镜面反射系数与菲涅尔项相同,代表镜面反射光线的占比
        vec3 kS = F;
        // 基于能量守恒,计算漫反射光线占比
        vec3 kD = vec3(1.0) - kS;
        // 金属度越高,漫反射就越少
        kD *= 1.0 - metallic;                   

        // NdotL表示光线垂直照射到表面的程度。越大,入射光越垂直,对总辐射率的贡献越大
        float NdotL = max(dot(N, L), 0.0);        

        // 计算此片段在此光源的作用下的出射辐射率Lo
        Lo += (kD * albedo / PI + specular) * radiance * NdotL; // specular项中已包含了菲涅尔
    }   

    // ---使用IBL计算环境光(间接光)---
    // 计算菲涅尔项
    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;    
    // 采样BRDFLUT,以获取BRDF项
    vec2 brdf  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
    // 计算镜面反射
    vec3 specular = prefilteredColor * (F * brdf.x + brdf.y);
    // 得到环境光
    vec3 ambient = (kD * diffuse + specular) * ao;

    vec3 color = ambient + Lo;

    // HDR色调映射
    color = color / (color + vec3(1.0));
    // 伽马矫正
    color = pow(color, vec3(1.0/2.2)); 

    FragColor = vec4(color , 1.0);