提示:各种渲染效果的切换需要手动在片段着色器中切换FragColor的赋值函数
目录
简介
效果图
一、开发流程
1.环境配置
2.框架搭建
二、基础功能
1.图形绘制
2.着色器
3.纹理材质
4.摄像机
三.核心功能
1.模型加载
2.光照算法
四.高阶功能
1.帧缓存
2.特殊材质效果-环境映射
3.阴影渲染
总结
简介
本OpenGL程序旨在通过OpenGL从底层出发,准许现实世界的真实物理表现,通过OpenGL实现一个基于物理的初级渲染管线。我们可以在OpenGL中自由的创建出我们想要的Model,然后也可以自由的为其添加材质贴图,最后我们通过着色器将我们的模型与材质数据输入按照一定的物理法则进行处理,进而实现在计算机中模拟出各种各样的画面表现。
效果图
玻璃折射
金属反射
标准材质
阴影渲染
一、开发流程
1.环境配置
为了更好的编写OpenGL,我们使用的许多功能完善的OpenGL的库,GLFW是一个专门针对OpenGL的C语言库,它提供了一些渲染物体所需的最低限度的接口。它允许用户创建OpenGL上下文、定义窗口参数以及处理用户输入,对我们来说这就够了。
我们在VS的NuGet包中安装各种我们所需要的库,这样比传统的计算机环境配置更加便捷
配置GLAD,GLAD是一个开源的库,它能解决我们上面提到的那个繁琐的问题。GLAD的配置与大多数的开源库有些许的不同,GLAD使用了一个在线服务。在这里我们能够告诉GLAD需要定义的OpenGL版本,并且根据这个版本加载所有相关的OpenGL函数。
GLAD现在应该提供给你了一个zip压缩文件,包含两个头文件目录,和一个glad.c文件。将两个头文件目录(glad和KHR)复制到你的Include文件夹中(或者增加一个额外的项目指向这些目录),并添加glad.c文件到你的工程中。
配置 Stb_image,为了将外部的纹理材质加载到OpenGL中来,我们使用了较为新颖的stb,为了能正常使用stb,我们将其作为头文件导入到项目中来,使用一下的的代码将其导入到cpp中
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
其余的OpenGL库的导入较为简单,我们只需要在NuGet包管理中将其导入即可,不需要额外的单独配置。
2.框架搭建
首先是创建一个可运行的空程序,我们第一步就是创建一个简单的窗口,然后整个程序进行初始化使得这个Opengl可以正常的运行。这里包含了我们项目最终所需要的所有的头文件以及包。
#include <glad/glad.h>
#include <GLFW/glfw3.h>#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/type_ptr.hpp>#include "Shader.h"
#include "camera.h"
#include "model.h"#include <iostream>
初始化程序并搭建窗口
int main()
{
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
//glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
return 0;
}
首先,我们在main函数中调用glfwInit函数来初始化GLFW,然后我们可以使用glfwWindowHint函数来配置GLFW。glfwWindowHint函数就是表明了我们所使用的的OpenGL的版本。
随后我们生成一个窗口对象,这个对象会被其他函数调用到。同时我们也会对这个生成的窗口进行检测来判断窗口是否成功的创建。
GLFWwindow* window = glfwCreateWindow(800, 600, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
我们也要检测Glad这个库是否被成功的导入到项目中来,所以我们需要检测一下。
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
在我们开始渲染图形前,我们还要告诉OpenGL的渲染窗口尺寸的大小,即视口的大小。
glViewport(0, 0, 800, 600);
glViewport函数前两个参数控制窗口左下角的位置。第三个和第四个参数控制渲染窗口的宽度和高度(像素)。
我们实际上也可以将视口的维度设置为比GLFW的维度小,这样子之后所有的OpenGL渲染将会在一个更小的窗口中显示,这样子的话我们也可以将一些其它元素显示在OpenGL视口之外。
然而,当用户改变窗口的大小的时候,视口也应该被调整。我们可以对窗口注册一个回调函数(Callback Function),它会在每次窗口大小被调整的时候被调用。这个回调函数的原型如下:
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
这个帧缓冲大小函数需要一个GLFWwindow作为它的第一个参数,以及两个整数表示窗口的新维度。每当窗口改变大小,GLFW会调用这个函数并填充相应的参数供你处理。
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
我们还需要注册这个函数,告诉GLFW我们希望每当窗口调整大小的时候调用这个函数:
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
我们最后要设置渲染循环,我们在进行退出之前OpenGL每时每刻的在实时更新他的渲染结果。当我们退出时,此时OpenGL停止渲染。我们可以通过一个简单的循环来实现这样的效果。
while(!glfwWindowShouldClose(window))
{
glfwSwapBuffers(window);
glfwPollEvents();
}
- glfwWindowShouldClose函数在我们每次循环的开始前检查一次GLFW是否被要求退出,如果是的话该函数返回true然后渲染循环便结束了,之后为我们就可以关闭应用程序了。
- glfwPollEvents函数检查有没有触发什么事件(比如键盘输入、鼠标移动等)、更新窗口状态,并调用对应的回调函数(可以通过回调方法手动设置)。
- glfwSwapBuffers函数会交换颜色缓冲(它是一个储存着GLFW窗口每一个像素颜色值的大缓冲),它在这一迭代中被用来绘制,并且将会作为输出显示在屏幕上。
最后,我们要在渲染结束后,清除之前所有的资源
glfwTerminate();
return 0;
最终的项目框架:
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <iostream>
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void processInput(GLFWwindow* window);
// 设置好生成的窗口的大小
const unsigned int SCR_WIDTH = 800;
const unsigned int SCR_HEIGHT = 600;
int main()
{
// glfw: GLFW的初始化
// ------------------------------
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
#ifdef __APPLE__
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
#endif
// 常见一个窗口
// --------------------
GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
if (window == NULL)
{
std::cout << "Failed to create GLFW window" << std::endl;
glfwTerminate();
return -1;
}
glfwMakeContextCurrent(window);
glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
// 检查Glad是否正确加载
// ---------------------------------------
if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
{
std::cout << "Failed to initialize GLAD" << std::endl;
return -1;
}
// 渲染的循环
// -----------
while (!glfwWindowShouldClose(window))
{
// 键鼠输入
// -----
processInput(window);
// 渲染
// ------
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// glfw: 我们需要交换缓存,这里使用了双缓存技术
// -------------------------------------------------------------------------------
glfwSwapBuffers(window);
glfwPollEvents();
}
// glfw: 清除所有的资源
// ------------------------------------------------------------------
glfwTerminate();
return 0;
}
// 键盘输入函数
// ---------------------------------------------------------------------------------------------------------
void processInput(GLFWwindow* window)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
}
// glfw: 实时将视口的长宽设置为窗口的长宽
// ---------------------------------------------------------------------------------------------
void framebuffer_size_callback(GLFWwindow* window, int width, int height)
{
glViewport(0, 0, width, height);
}
二、基础功能
1.图形绘制
在OpenGL中,图形的绘制首先要获取图形的数据,这包括顶点的位置,法相,UV等等,有了这些的顶点数据我们就能知道图形最基本的显示效果。下一步就是如何创建顶点信息并将其储存在OpenGL的缓存中,并告诉OpenGL如何使用这些顶点数据。
- 顶点数组对象:Vertex Array Object,VAO
- 顶点缓冲对象:Vertex Buffer Object,VBO
- 元素缓冲对象:Element Buffer Object,EBO 或 索引缓冲对象 Index Buffer Object,IBO
在OpenGL中,所有的顶点数据都被储存在这三个对象之中,而所有的顶点数据往往都被储存在一个数组中,我们下一步就是解析这些数组。
首先是VBO,这里面储存着具体的顶点数据,他讲数组中储存的数据进行打组分类,为他们标记出不同的用处,然后我们就能在别的地方直接调用这些分好类的数据。、
我们声明一个简单的顶点数据,这里面储存的是顶点的位置信息XYZ,也就是每三个数据代表一个顶点,这里面只是最为简单的顶点位置数据
float vertices[] = {
-0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.0f, 0.5f, 0.0f
};
创建一个VBO并将其绑定随后为其填充具体的数据,这也是不同的对象的统一的套路
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
我们将VBO绑定到专门储存顶点数据的顶点缓冲对象的缓冲类型中GL_ARRAY_BUFFER
glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数。它的第一个参数是目标缓冲的类型:顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上。第二个参数指定传输数据的大小(以字节为单位);用一个简单的sizeof计算出顶点数据大小就行。第三个参数是我们希望发送的实际数据。
第四个参数指定了我们希望显卡如何管理给定的数据。它有三种形式:
- GL_STATIC_DRAW :数据不会或几乎不会改变。
- GL_DYNAMIC_DRAW:数据会被改变很多。
- GL_STREAM_DRAW :数据每次绘制时都会改变。
创建一个VAO,这和创建一个VBO是相通的。顶点数组对象(Vertex Array Object, VAO)可以像顶点缓冲对象那样被绑定,任何随后的顶点属性调用都会储存在这个VAO中。这样的好处就是,当配置顶点属性指针时,你只需要将那些调用执行一次,之后再绘制物体的时候只需要绑定相应的VAO就行了。这使在不同顶点数据和属性配置之间切换变得非常简单,只需要绑定不同的VAO就行了。刚刚设置的所有状态都将存储在VAO中。
unsigned int VAO;
glGenVertexArrays(1, &VAO);
glBindVertexArray(VAO);
我们绑定VAO后再去绑定VBO那么VAO就和和这个VBO产生对应关系,当我们下次需要使用到这个VBO时,我们只需要绑定VAO就能得到对应VBO数据,一个VAO对应一个VBO。
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
//创建一个缓冲对象
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 当我们创建并且为其绑定好时,我们一般为其恢复为零设置为起始状态,这样我们在有很多VAO与VBO时就不会出现匹配混乱的问题,等到使用的时候我们只需单独绑定VAO即可。
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
最后是EBO,他储存的是顶点渲染顺序, 因为在OPenGL中,我们是通过渲染很多的三角面来组成一个model,但是对于渲染一个矩形来讲,我们需要两个三角形来拼接而成,也就是意味着我们需要6个顶点来渲染一个具有4个顶点的物体,那么也就一定存在着顶点的重复,为了解决绘制时顶点重复的问题,我们可以通过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, // 右下角
-0.5f, -0.5f, 0.0f, // 左下角
-0.5f, 0.5f, 0.0f // 左上角
};
使用了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 // 第二个三角形
};
同理,创建EBO并绑定。
unsigned int EBO;
glGenBuffers(1, &EBO);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
这样下次再渲染时只需要绑定EBO就能使用这个EBO进行渲染了。
2.着色器
着色器(Shader)是运行在GPU上的小程序。这些小程序为图形渲染管线的某个特定部分而运行。从基本意义上来说,着色器只是一种把输入转化为输出的程序。着色器也是一种非常独立的程序,因为它们之间不能相互通信;它们之间唯一的沟通只有通过输入和输出。
前面我们对输入进来的顶点数据进行的处理,我们将顶点数据储存在VBO中,但是当顶点数据包含更多的信息时,我们一般要对VBO中的数据进行区分。
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 // 左上
};
这时我们首先要在顶点着色器中定义不同的顶点属性
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为 0
layout (location = 1) in vec3 aColor; // 颜色变量的属性位置值为 1
layout (location = 2) in vec2 TexCoords; // UV的位置属性为2
随后我们在glVertexAttribPointer进行指定
// 位置属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);
// 颜色属性
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3* sizeof(float)));
glEnableVertexAttribArray(1);
// UV属性
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(6* sizeof(float)));
glEnableVertexAttribArray(2);
这样我们就完成了所有的顶点信息的配置!!!!!
顶点着色器:顶点着色器是对 模型的顶点数据进行处理,主要是将其坐标空间进行转换,转换到裁剪空间。
#version 330 core
layout (location = 0) in vec3 aPos; // 位置变量的属性位置值为0
out vec4 vertexColor; // 为片段着色器指定一个颜色输出
void main()
{
gl_Position = vec4(aPos, 1.0); // 注意我们如何把一个vec3作为vec4的构造器的参数
vertexColor = vec4(0.5, 0.0, 0.0, 1.0); // 把输出变量设置为暗红色
}
片段着色器:片段着色器能将顶点着色器处理的顶点通过插值转换片元,并为其附上颜色最终转变为屏幕中像素。
#version 330 core
out vec4 FragColor;
in vec4 vertexColor; // 从顶点着色器传来的输入变量(名称相同、类型相同)
void main()
{
FragColor = vertexColor;
}
3.纹理材质
为了使得物体材质的表现更加丰富,我们可以为其配置纹理,并让片段着色器在其材质上进行采样,最终物体就能表现出纹理材质的颜色。
纹理的创建和前面的缓冲对象非常类似。
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);
我们使用stb-image.h导入材质。stb_image.h是Sean Barrett的一个非常流行的单头文件图像加载库,它能够加载大部分流行的文件格式,并且能够很简单得整合到你的工程之中。
int width, height, nrChannels;
unsigned char *data = stbi_load("container.jpg", &width, &height, &nrChannels, 0);
将导入的纹理数据绑定到纹理缓冲中
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glGenerateMipmap(GL_TEXTURE_2D);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data);当前绑定的纹理对象就会被附加上纹理图像
第一个参数指定了纹理目标(Target)。设置为GL_TEXTURE_2D意味着会生成与当前绑定的纹理对象在同一个目标上的纹理(任何绑定到GL_TEXTURE_1D和GL_TEXTURE_3D的纹理不会受到影响)。
第二个参数为纹理指定多级渐远纹理的级别,如果你希望单独手动设置每个多级渐远纹理的级别的话。这里我们填0,也就是基本级别。
第三个参数告诉OpenGL我们希望把纹理储存为何种格式。我们的图像只有RGB值,因此我们也把纹理储存为RGB值。
第四个和第五个参数设置最终的纹理的宽度和高度。我们之前加载图像的时候储存了它们,所以我们使用对应的变量。
下个参数应该总是被设为0(历史遗留的问题)。
第七第八个参数定义了源图的格式和数据类型。我们使用RGB值加载这个图像,并把它们储存为char(byte)数组,我们将会传入对应值。
最后一个参数是真正的图像数据。
最后我们需要应用材质,我们需要为顶点数据添加上纹理坐标,每一个顶点都具有自己纹理坐标。
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 // 左上
};
最后,我们只需要在片段着色器中进行采样就可在物体上应用材质,我们需要在顶点着色器中接受纹理坐标,并且传递给片段着色器。同时在渲染物体时我们也需要在绘制之前绑定好材质。
#version 330 core
out vec4 FragColor;
in vec3 ourColor;
in vec2 TexCoord;
uniform sampler2D ourTexture;
void main()
{
FragColor = texture(ourTexture, TexCoord);
}
4.摄像机
为了将我们绘制的物体呈现在屏幕上,我们还需要设置物体映射在屏幕上的方式,这也就是我们常说的添加一个摄像机的功能。
当我们讨论摄像机/观察空间(Camera/View Space)的时候,是在讨论以摄像机的视角作为场景原点时场景中所有的顶点坐标:观察矩阵把所有的世界坐标变换为相对于摄像机位置与方向的观察坐标。要定义一个摄像机,我们需要它在世界空间中的位置、观察的方向、一个指向它右侧的向量以及一个指向它上方的向量。细心的读者可能已经注意到我们实际上创建了一个三个单位轴相互垂直的、以摄像机的位置为原点的坐标系。
我们要让摄像机空间内看到的物体映射到屏幕上,我们需要对顶点进行矩阵变换,这个矩阵我们称之为投影矩阵
glm::mat4 projection;
projection = glm::perspective(glm::radians(45.0f), screenWidth / screenHeight, 0.1f, 100.0f);
随后是观察矩阵,他决定了摄像机看向哪里,他看向哪里,相机的上方向指向哪里。
glm::mat4 view;
view = glm::lookAt(glm::vec3(camX, 0.0, camZ), glm::vec3(0.0, 0.0, 0.0), glm::vec3(0.0, 1.0, 0.0));
为了更好地观察OpenGL的绘制情况,我们要自定义一个摄像机类,在这里我们能够自由的OpenGL的空间里自由穿梭,我们可以通过鼠标和键盘实现摄像机控制的功能。
view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
void processInput(GLFWwindow *window)
{
...
float cameraSpeed = 0.05f; // adjust accordingly
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
cameraPos += cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
cameraPos -= cameraSpeed * cameraFront;
if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
}
我们通过键盘来控制摄像机位置的移动。 为了更加平滑的运动,我们需要对Speed进行修改。
全局变量
float deltaTime = 0.0f; // 当前帧与上一帧的时间差
float lastFrame = 0.0f; // 上一帧的时间
在渲染循环里计算每一帧的时间
float currentFrame = glfwGetTime();
deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
void processInput(GLFWwindow *window)
{
将移动速度设置为每秒
float cameraSpeed = 2.5f * deltaTime;
...
}
随后我们实现鼠标控制相机旋转的功能 ,我们获取鼠标的偏移量并将其转化为XY方向的角度,根据角度来决定相机的视线方向。
float xoffset = xpos - lastX;
float yoffset = lastY - ypos; // 注意这里是相反的,因为y坐标是从底部往顶部依次增大的
lastX = xpos;
lastY = ypos;
float sensitivity = 0.05f;
xoffset *= sensitivity;
yoffset *= sensitivity;
yaw += xoffset;
pitch += yoffset;
if(pitch > 89.0f)
pitch = 89.0f;
if(pitch < -89.0f)
pitch = -89.0f;
glm::vec3 front;
front.x = cos(glm::radians(pitch)) * cos(glm::radians(yaw));
front.y = sin(glm::radians(pitch));
front.z = cos(glm::radians(pitch)) * sin(glm::radians(yaw));
cameraFront = glm::normalize(front);
摄像机类
#pragma once
#ifndef CAMERA_H
#define CAMERA_H
#include <glad/glad.h>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <vector>
// 通过枚举定义不同的摄像机移动状态
enum Camera_Movement {
FORWARD,
BACKWARD,
LEFT,
RIGHT
};
// Default camera values
const float YAW = -90.0f;
const float PITCH = 0.0f;
const float SPEED = 2.5f;
const float SENSITIVITY = 0.1f;
const float ZOOM = 45.0f;
class Camera
{
public:
// 相机的属性
glm::vec3 Position;
glm::vec3 Front;
glm::vec3 Up;
glm::vec3 Right;
glm::vec3 WorldUp;
// 旋转角度
float Yaw;
float Pitch;
// 相机位置
float MovementSpeed;
float MouseSensitivity;
float Zoom;
// constructor with vectors
Camera(glm::vec3 position = glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3 up = glm::vec3(0.0f, 1.0f, 0.0f), float yaw = YAW, float pitch = PITCH) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM)
{
Position = position;
WorldUp = up;
Yaw = yaw;
Pitch = pitch;
updateCameraVectors();
}
// constructor with scalar values
Camera(float posX, float posY, float posZ, float upX, float upY, float upZ, float yaw, float pitch) : Front(glm::vec3(0.0f, 0.0f, -1.0f)), MovementSpeed(SPEED), MouseSensitivity(SENSITIVITY), Zoom(ZOOM)
{
Position = glm::vec3(posX, posY, posZ);
WorldUp = glm::vec3(upX, upY, upZ);
Yaw = yaw;
Pitch = pitch;
updateCameraVectors();
}
// 返回相机的观察和矩阵
glm::mat4 GetViewMatrix()
{
return glm::lookAt(Position, Position + Front, Up);
}
// 键盘交互
void ProcessKeyboard(Camera_Movement direction, float deltaTime)
{
float velocity = MovementSpeed * deltaTime;
if (direction == FORWARD)
Position += Front * velocity;
if (direction == BACKWARD)
Position -= Front * velocity;
if (direction == LEFT)
Position -= Right * velocity;
if (direction == RIGHT)
Position += Right * velocity;
}
// 接受鼠标的没整偏移
void ProcessMouseMovement(float xoffset, float yoffset, GLboolean constrainPitch = true)
{
xoffset *= MouseSensitivity;
yoffset *= MouseSensitivity;
Yaw += xoffset;
Pitch += yoffset;
// 范围限制
if (constrainPitch)
{
if (Pitch > 89.0f)
Pitch = 89.0f;
if (Pitch < -89.0f)
Pitch = -89.0f;
}
// 更新方向向量
updateCameraVectors();
}
void ProcessMouseScroll(float yoffset)
{
Zoom -= (float)yoffset;
if (Zoom < 1.0f)
Zoom = 1.0f;
if (Zoom > 45.0f)
Zoom = 45.0f;
}
private:
void updateCameraVectors()
{
// calculate the new Front vector
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));
Front = glm::normalize(front);
// 通过点成产生
Right = glm::normalize(glm::cross(Front, WorldUp));
Up = glm::normalize(glm::cross(Right, Front));
}
};
#endif
三.核心功能
1.模型加载
从Assimp的数据结构中提取我们所需的所有数据了。由于Assimp的数据结构保持不变,不论导入的是什么种类的文件格式,它都能够将我们从这些不同的文件格式中抽象出来,用同一种方式访问我们需要的数据。
当使用Assimp导入一个模型的时候,它通常会将整个模型加载进一个场景(Scene)对象,它会包含导入的模型/场景中的所有数据。Assimp会将场景载入为一系列的节点(Node),每个节点包含了场景对象中所储存数据的索引,每个节点都可以有任意数量的子节点。Assimp数据结构的(简化)模型如下:
- 和材质和网格(Mesh)一样,所有的场景/模型数据都包含在Scene对象中。Scene对象也包含了场景根节点的引用。
- 场景的Root node(根节点)可能包含子节点(和其它的节点一样),它会有一系列指向场景对象中mMeshes数组中储存的网格数据的索引。Scene下的mMeshes数组储存了真正的Mesh对象,节点中的mMeshes数组保存的只是场景中网格数组的索引。
- 一个Mesh对象本身包含了渲染所需要的所有相关数据,像是顶点位置、法向量、纹理坐标、面(Face)和物体的材质。
- 一个网格包含了多个面。Face代表的是物体的渲染图元(Primitive)(三角形、方形、点)。一个面包含了组成图元的顶点的索引。由于顶点和索引是分开的,使用一个索引缓冲来渲染是非常简单的(见你好,三角形)。
- 最后,一个网格也包含了一个Material对象,它包含了一些函数能让我们获取物体的材质属性,比如说颜色和纹理贴图(比如漫反射和镜面光贴图)。
所以,我们需要做的第一件事是将一个物体加载到Scene对象中,遍历节点,获取对应的Mesh对象(我们需要递归搜索每个节点的子节点),并处理每个Mesh对象来获取顶点数据、索引以及它的材质属性。最终的结果是一系列的网格数据,我们会将它们包含在一个Model对象中。
2.光照算法
现实世界的光照是极其复杂的,而且会受到诸多因素的影响,这是我们有限的计算能力所无法模拟的。因此OpenGL的光照使用的是简化的模型,对现实的情况进行近似,这样处理起来会更容易一些,而且看起来也差不多一样。这些光照模型都是基于我们对光的物理特性的理解。其中一个模型被称为冯氏光照模型(Phong Lighting Model)。冯氏光照模型的主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。下面这张图展示了这些光照分量看起来的样子:
- 环境光照(Ambient Lighting):即使在黑暗的情况下,世界上通常也仍然有一些光亮(月亮、远处的光),所以物体几乎永远不会是完全黑暗的。为了模拟这个,我们会使用一个环境光照常量,它永远会给物体一些颜色。
- 漫反射光照(Diffuse Lighting):模拟光源对物体的方向性影响(Directional Impact)。它是冯氏光照模型中视觉上最显著的分量。物体的某一部分越是正对着光源,它就会越亮。
- 镜面光照(Specular Lighting):模拟有光泽物体上面出现的亮点。镜面光照的颜色相比于物体的颜色会更倾向于光的颜色。
我们将会先使用一个简化的全局照明模型,即环境光照。正如你在上一节所学到的,我们使用一个很小的常量(光照)颜色,添加到物体片段的最终颜色中,这样子的话即便场景中没有直接的光源也能看起来存在有一些发散的光。
把环境光照添加到场景里非常简单。我们用光的颜色乘以一个很小的常量环境因子,再乘以物体的颜色,然后将最终结果作为片段的颜色:
void main()
{
float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;
vec3 result = ambient * objectColor;
FragColor = vec4(result, 1.0);
}
环境光照本身不能提供最有趣的结果,但是漫反射光照就能开始对物体产生显著的视觉影响了。漫反射光照使物体上与光线方向越接近的片段能从光源处获得更多的亮度。为了能够更好的理解漫反射光照,请看下图:
编辑图左上方有一个光源,它所发出的光线落在物体的一个片段上。我们需要测量这个光线是以什么角度接触到这个片段的。为了测量光线和片段的角度,我们使用一个叫做法向量(Normal Vector)的东西,它是垂直于片段表面的一个向量(这里以黄色箭头表示)。
out vec3 FragPos;
out vec3 Normal;
void main()
{
gl_Position = projection * view * model * vec4(aPos, 1.0);
FragPos = vec3(model * vec4(aPos, 1.0));
Normal = aNormal;
}
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor;
如果两个向量之间的角度大于90度,点乘的结果就会变成负数,这样会导致漫反射分量变为负数。为此,我们使用max函数返回两个参数之间较大的参数,从而保证漫反射分量不会变成负数。
和漫反射光照一样,镜面光照也决定于光的方向向量和物体的法向量,但是它也决定于观察方向,例如玩家是从什么方向看向这个片段的。
我们通过根据法向量翻折入射光的方向来计算反射向量。然后我们计算反射向量与观察方向的角度差,它们之间夹角越小,镜面光的作用就越大。由此产生的效果就是,我们看向在入射光在表面的反射方向时,会看到一点高光。
float specularStrength = 0.5;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32);
vec3 specular = specularStrength * spec * lightColor;
vec3 result = (ambient + diffuse + specular) * objectColor;
FragColor = vec4(result, 1.0);
这样我们就对三种光照的基础结构完成了,下一步我们将其运用到具体的片段着色器中。
光照就必须要有光源, 我们实现了三种光源,分别为点光源、平行光、射灯。
我们可以定义一个光线方向向量而不是位置向量来模拟一个定向光。着色器的计算基本保持不变,但这次我们将直接使用光的direction向量而不是通过position来计算lightDir向量。
struct Light {
// vec3 position; // 使用定向光就不再需要了
vec3 direction;
vec3 ambient;
vec3 diffuse;
vec3 specular;
};
...
void main()
{
vec3 lightDir = normalize(-light.direction);
...
}
点光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离逐渐衰减。想象作为投光物的灯泡和火把,它们都是点光源。 下面这个公式根据片段距光源的距离计算了衰减值,之后我们会将它乘以光的强度向量:
在这里dd代表了片段距光源的距离。接下来为了计算衰减值,我们定义3个(可配置的)项:常数项KcKc、一次项KlKl和二次项KqKq。
- 常数项通常保持为1.0,它的主要作用是保证分母永远不会比1小,否则的话在某些距离上它反而会增加强度,这肯定不是我们想要的效果。
- 一次项会与距离值相乘,以线性的方式减少强度。
- 二次项会与距离的平方相乘,让光源以二次递减的方式减少强度。二次项在距离比较小的时候影响会比一次项小很多,但当距离值比较大的时候它就会比一次项更大了。
float distance = length(light.position - FragPos);
float attenuation = 1.0 / (light.constant + light.linear * distance +
light.quadratic * (distance * distance));
OpenGL中聚光是用一个世界空间位置、一个方向和一个切光角(Cutoff Angle)来表示的,切光角指定了聚光的半径(译注:是圆锥的半径不是距光源距离那个半径)。对于每个片段,我们会计算片段是否位于聚光的切光方向之间(也就是在锥形内),如果是的话,我们就会相应地照亮片段
float theta = dot(lightDir, normalize(-light.direction));
if(theta > light.cutOff)
{
// 执行光照计算
}
else // 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);
了创建一种看起来边缘平滑的聚光,我们需要模拟聚光有一个内圆锥(Inner Cone)和一个外圆锥(Outer Cone)。我们可以将内圆锥设置为上一部分中的那个圆锥,但我们也需要一个外圆锥,来让光从内圆锥逐渐减暗,直到外圆锥的边界。
float theta = dot(lightDir, normalize(-light.direction));
float epsilon = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
...
// 将不对环境光做出影响,让它总是能有一点光
diffuse *= intensity;
specular *= intensity;
...
最终,我们实现了三种光照效果:
vec3 CalcDirlight(DirLight dirlight,vec3 normal,vec3 vivedir)
{
//漫反射计算时,光线地方向为从片段指向灯光,因为法线是垂直平面向外的,这样在计算光线与法线的夹角时才能得到0-90的结果,进而得出正确的diff。
vec3 direction=fs_in.TBN*normalize(-dirlight.direction);
float diff=max(dot(direction,normal),0);
//计算镜面反射时,我们需要光线地方向指向片元,因为我们需要通过reflect的计算得出正确的反射光线,所以我们对direction进行取反。
vec3 reflectdir=normalize(reflect(-direction,normal));
float specular=pow(max(dot(reflectdir,vivedir),0),material.shininess);
vec3 ambient=dirlight.ambient*texture(material.texture_diffuse1,fs_in.TexCoords).rgb;
vec3 outcolor=ambient+dirlight.diffuse*diff*texture(material.texture_diffuse1,fs_in.TexCoords).rgb+dirlight.specular*specular*texture(material.texture_specular1,fs_in.TexCoords).rgb;
return outcolor;
}
vec3 CalcPointlight(PointLight pointlight,vec3 normal,vec3 vivepos,vec3 fragpos)
{
float distance=length(pointlight.position-fragpos);
float attenuation=1.0/(pointlight.constp+pointlight.liner*distance+pointlight.quadratic*(distance*distance));
vec3 direction=normalize(fragpos-pointlight.position);
vec3 reflectdir=normalize(reflect(direction,normal));
float diff=max(dot(normal,-direction),0);
float spec=pow(max(dot(reflectdir,vivepos),0),material.shininess);
vec3 ambient=pointlight.ambient*vec3(texture(material.diffuse,TexCoord));
vec3 diffuse=pointlight.diffuse*diff*texture(material.diffuse,TexCoord).rgb;
vec3 specular=pointlight.specular*spec*texture(material.specular,TexCoord).rgb;
vec3 outcolor=(ambient+specular+diffuse)*attenuation;
return outcolor;
}
vec3 CalcSpotlight(SpotLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
vec3 lightDir = normalize(light.position - fragPos);
// diffuse shading
float diff = max(dot(normal, lightDir), 0.0);
// specular shading
vec3 reflectDir = reflect(-lightDir, normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), material.shininess);
// attenuation
float distance = length(light.position - fragPos);
float attenuation = 1.0 / (light.constp + light.liner * distance + light.quadratic * (distance * distance));
// spotlight intensity
float theta = dot(-lightDir, normalize(light.direction));
float epsilon = light.cutoff - light.outcutoff;
float intensity = clamp((theta - light.outcutoff) / epsilon, 0.0, 1.0);
// combine results
vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoord));
vec3 diffuse = light.diffuse * diff * vec3(texture(material.diffuse, TexCoord));
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoord));
ambient *= attenuation * intensity;
diffuse *= attenuation * intensity;
specular *= attenuation * intensity;
return (ambient + diffuse + specular);
}
四.高阶功能
1.帧缓存
为了实现更多高级的画面效果,我们使用了帧缓存,用于写入颜色值的颜色缓冲、用于写入深度信息的深度缓冲和允许我们根据一些条件丢弃特定片段的模板缓冲。这些缓冲结合起来叫做帧缓冲(Framebuffer),它被储存在内存中。OpenGL允许我们定义我们自己的帧缓冲,也就是说我们能够定义我们自己的颜色缓冲,甚至是深度缓冲和模板缓冲。
我们之前都是使用的一个默认的帧缓存,而默认的帧缓存是GLFW在窗口的创建的时候就为我们配置好了。
我们可以将一个帧缓存理解为一个渲染通道,这个通道里他有自己单独的深度颜色模板的缓存,我们可以将他展示的屏幕窗口中也可以不显示在画面里,我们将其称之为离屏渲染。
帧缓存的创建和OpenGL中的其它对象一样,我们会使用一个叫做glGenFramebuffers的函数来创建一个帧缓冲对象(Framebuffer Object, FBO)
unsigned int fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
在绑定到GL_FRAMEBUFFER目标之后,所有的读取和写入帧缓冲的操作将会影响当前绑定的帧缓冲。
但是一个帧缓存还包含了很多的附件,一个完整的帧缓存还必须包含:
- 附加至少一个缓冲(颜色、深度或模板缓冲)。
- 至少有一个颜色附件(Attachment)。
- 所有的附件都必须是完整的(保留了内存)。
- 每个缓冲都应该有相同的样本数。
纹理附件
当把一个纹理附加到帧缓冲的时候,所有的渲染指令将会写入到这个纹理中,就像它是一个普通的颜色/深度或模板缓冲一样。使用纹理的优点是,所有渲染操作的结果将会被储存在一个纹理图像中,我们之后可以在着色器中很方便地使用它。
为帧缓冲创建一个纹理和创建一个普通的纹理差不多:
unsigned int texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
我们并没有为其分配数据,因为这个数据将有帧缓存为期配置。随后将其配置到帧缓存中
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
深度缓冲附件
为了后面能够正常的使用阴影,我们还需要配置一个深度缓冲的附件,他会将画面的深度值缓冲到纹理之中。
glTexImage2D(
GL_TEXTURE_2D, 0, GL_DEPTH24_STENCIL8, 800, 600, 0,
GL_DEPTH_STENCIL, GL_UNSIGNED_INT_24_8, NULL
);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
我们只需要更改一下绑定到帧缓存时他的绑定类型为GL_DEPTH_STENCIL_ATTACHMENT
2.特殊材质效果-环境映射
反射,反射这个属性表现为物体(或物体的一部分)反射它周围环境,即根据观察者的视角,物体的颜色或多或少等于它的环境。镜子就是一个反射性物体:它会根据观察者的视角反射它周围的环境。
为了表现出周围环境的光照表现,我们需要从环境贴图中进行采样。
如果我们假设将这样的立方体贴图应用到一个立方体上,采样立方体贴图所使用的方向向量将和立方体(插值的)顶点位置非常相像。这样子,只要立方体的中心位于原点,我们就能使用立方体的实际位置向量来对立方体贴图进行采样了。接下来,我们可以将所有顶点的纹理坐标当做是立方体的顶点位置。最终得到的结果就是可以访问立方体贴图上正确面(Face)纹理的一个纹理坐标。
我们只需要获取顶点的坐标作为采样立方体坐标的纹理坐标
顶点着色器
#version 330 core
layout (location = 0) in vec3 aPos;
out vec3 TexCoords;
uniform mat4 projection;
uniform mat4 view;
void main()
{
TexCoords = aPos;
gl_Position = projection * view * vec4(aPos, 1.0);
}
片段着色器
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
随后进行反射的光照算法
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 Position;
uniform vec3 cameraPos;
uniform samplerCube skybox;
void main()
{
vec3 I = normalize(Position - cameraPos);
vec3 R = reflect(I, normalize(Normal));
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
折射 ,环境映射的另一种形式是折射,它和反射很相似。折射是光线由于传播介质的改变而产生的方向变化。在常见的类水表面上所产生的现象就是折射,光线不是直直地传播,而是弯曲了一点。将你的半只胳膊伸进水里,观察出来的就是这种效果。
void main()
{
float ratio = 1.00 / 1.52;
vec3 I = normalize(Position - cameraPos);
vec3 R = refract(I, normalize(Normal), ratio);
FragColor = vec4(texture(skybox, R).rgb, 1.0);
}
3.阴影渲染
阴影映射(Shadow Mapping)背后的思路非常简单:我们以光的位置为视角进行渲染,我们能看到的东西都将被点亮,看不见的一定是在阴影之中了。假设有一个地板,在光源和它之间有一个大盒子。由于光源处向光线方向看去,可以看到这个盒子,但看不到地板的一部分,这部分就应该在阴影中了。
就是说我们先渲染出以灯光为视角的灯光空间中的深度贴图,随后我们获取顶点在世界空间中的位置,通过灯光的光源空间变换矩阵,将其变换到光源空间中,获取他的深度值,对比前面深度贴图中的深度,如果深度值大于贴图中的深度值,我们就认为这个点处于阴影之中,
首先渲染出灯源空间的深度贴图,我们使用的方向光。深度贴图的渲染就按照前面的帧缓存中的深度贴图附件的方法。
光源空间,我们从光的位置的视野下使用了不同的投影和视图矩阵来渲染的场景。因为我们使用的是一个所有光线都平行的定向光。出于这个原因,我们将为光源使用正交投影矩阵,透视图将没有任何变形。
GLfloat near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);
为了创建一个视图矩阵来变换每个物体,把它们变换到从光源视角可见的空间中,我们将使用glm::lookAt函数;这次从光源的位置看向场景中央。
glm::mat4 lightView = glm::lookAt(glm::vec(-2.0f, 4.0f, -1.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
这样我们就创建出了光源空间的转换矩阵
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
有了光源转换矩阵,下一步我们就要进行应用,我们通过顶点着色器将顶点全部转换到光源空间下,片段着色器为空,因为我们只需拿顶点着色器进行空间的转换,而不需要片段着色器进行任何颜色的产生,我们只需要深度贴图
#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);
}
随后我们就是绑定帧缓存,然后渲染。
simpleDepthShader.Use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glClear(GL_DEPTH_BUFFER_BIT);
RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);
这样我们就在纹理中储存了光源空间下的深度贴图,随后我们就要正常的渲染场景,并应用深度贴图产生阴影。
我们需要在顶点着色器中对顶点进行光源空间的坐标转换,方便我们在片段着色器中进行应用。
#version 330 core
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 texCoords;
out vec2 TexCoords;
out VS_OUT {
vec3 FragPos;
vec3 Normal;
vec2 TexCoords;
vec4 FragPosLightSpace;
} vs_out;
uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform mat4 lightSpaceMatrix;
void main()
{
gl_Position = projection * view * model * vec4(position, 1.0f);
vs_out.FragPos = vec3(model * vec4(position, 1.0));
vs_out.Normal = transpose(inverse(mat3(model))) * normal;
vs_out.TexCoords = texCoords;
vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
}
有了光源空间的坐标,我们需要对每个顶点进行判断,决定他是否在阴影中。
float ShadowCalculation(vec4 fragPosLightSpace)
{
// 执行透视除法
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// 变换到[0,1]的范围
projCoords = projCoords * 0.5 + 0.5;
// 取得最近点的深度(使用[0,1]范围下的fragPosLight当坐标)
float closestDepth = texture(shadowMap, projCoords.xy).r;
// 取得当前片段在光源视角下的深度
float currentDepth = projCoords.z;
// 检查当前片段是否在阴影中
float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
return shadow;
}
注意:因为阴影贴图受限于分辨率,在距离光源比较远的情况下,多个片段可能从深度贴图的同一个值中去采样。图片每个斜坡代表深度贴图一个单独的纹理像素。你可以看到,多个片段从同一个深度值进行采样。
虽然很多时候没问题,但是当光源以一个角度朝向表面的时候就会出问题,这种情况下深度贴图也是从一个角度下进行渲染的。多个片段就会从同一个斜坡的深度纹理像素中采样,有些在地板上面,有些在地板下面;这样我们所得到的阴影就有了差异。因为这个,有些片段被认为是在阴影之中,有些不在,由此产生了图片中的条纹样式。
我们可以用一个叫做阴影偏移(shadow bias)的技巧来解决这个问题,我们简单的对表面的深度(或深度贴图)应用一个偏移量,这样片段就不会被错误地认为在表面之下了。
float bias = 0.005;
float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
最后我们将每个顶点的阴影值放到光照计算中,我们就能实现光的阴影效果了。
void main()
{
vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
vec3 normal = normalize(fs_in.Normal);
vec3 lightColor = vec3(1.0);
// Ambient
vec3 ambient = 0.15 * color;
// Diffuse
vec3 lightDir = normalize(lightPos - fs_in.FragPos);
float diff = max(dot(lightDir, normal), 0.0);
vec3 diffuse = diff * lightColor;
// Specular
vec3 viewDir = normalize(viewPos - fs_in.FragPos);
vec3 reflectDir = reflect(-lightDir, normal);
float spec = 0.0;
vec3 halfwayDir = normalize(lightDir + viewDir);
spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
vec3 specular = spec * lightColor;
// 计算阴影
float shadow = ShadowCalculation(fs_in.FragPosLightSpace);
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;
FragColor = vec4(lighting, 1.0f);
}
总结
至此,我们实现了一个简易的物理渲染模型,它遵循一定的物理法则,通过特定的光照算法,加载物体的物理维度描述数据,去模拟物体在现实世界中的真实光照表现。我们还其添加了很多有趣的渲染效果,例如玻璃或者镜面的反射等等。文章来源:https://www.toymoban.com/news/detail-407793.html
但同时他还不是严格意义上的PBR物理渲染管线,下一步我们将为其完善功能,实现一个完整的PBR渲染管线。文章来源地址https://www.toymoban.com/news/detail-407793.html
到了这里,关于大作业——OpenGL-基于物理的渲染挂线项目说明书的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!