引言:
本文主要用来讲解PBR技术的数学原理和简单实现:
微表面模型理论
在前面的学习过程中,一般采用是一个漫反射量和一个高光的反射量,其BRDF函数是这二个的和:
这种光照模型有很大的局限,没有使用到材质相关,表面的粗糙程度,能量守恒方面都有一些问题。
在这种情况下出现了一个微表面模型,表面假象为一个粗糙的结构,微观的法线是不规则的,同时可能会出现遮挡的情况,这样能够模拟现实中的粗糙的物体,当然,漫反射的法线值和这个无关,在有了这种的思路的情况下,出现了cook-torrance模型,对于这一种模型,其BRDF函数如下:
这里出现了三个新的概念,D,F,G他们一起描述了一个完整的微表面的结构,w(o)是指向相机的向量,w(i)是指向光源的向量,关于D,F,G下面在做分析。
法线分布函数D:
在Blinn-phong模型之中使用半程向量L=normalize(v+l),这里也使用类似的思路,使用半程向量,由于表面粗糙程度的变化,引入粗糙程度a,由一个GGX的人提出了他的D的经验公式:
L指向光源,V指向视口,a是粗糙度的平方,但是有的也直接使用a等于粗糙度,这个一般是看效果得出的。当a很大的时候,趋近于1的时候,无论下面的(n*h)有多大,D值都相差不大,材质中都有相似的高光值。同时该公式可以保证真正的能量守恒。
几何函数G
对于一个凹凸不平表面,有二个基本的现象,一是自阴影,第二是自遮挡,自阴影是照射的过程中被遮挡住了,自遮挡是反射到相机被遮挡到了。这里使用一个k值来衡量这两个的程度的大小来衡量接受到的值由多少:
这里的a是粗糙度,l指向光源,v指向视口。当没有光线的时候也不会完全没有任何的显示。
菲尼尔方程F
在前面写的时候,可能会发现没有高光的系数的Ks,其实F就是ks,因为有能量守恒的存在,我们需要先确定反射的占比,然后确定漫反射占比,关于菲尼尔方程和材质相关,有一个初始的F(0),当光线垂直入射的时候,透射很大,平行的时候,反射较大,这里有一个近似的计算公式:
当垂直于表面的时候,dot(h,v)=1,F=F(0),也就是ks=0,没有高光反射,全部为漫反射,当平行的时候,F=1,kd=0,没有漫反射。于是cook-torrance的BRDF为:
目前我们就有了完整BRDF函数,现在使用opengl来进行一下简单的实现。
opengl的实现PBR
首先需要确定一下参数,哪些是我们需要的参数,对于pbr而言,最基础的参数为albedo(漫反射分量)、metallic(金属度)、roughness(粗糙度)、specular(高光)。除了以上微表面模型相关的参数外,还会有一个ao(环境光遮蔽)、一个normalmap(法线贴图)。
albedo对应着漫反射项中的c,指材质表面本身的颜色; metallic对应着菲涅尔项中的F0,当metallic拉到最大时,材质处处反射率恒为0,这样符合金属的性质; roughness对应着D、G项中的alpha;specular项要特别注意,它本身不存在于cook-torrance中,高光分量的比例应该完全由菲涅尔项F控制,只是有时候为了追求效果,我们可能会对高光量做一个额外的衰减,这个衰减系数就是specular。然而如果我们动了这一项,那么会导致材质模型有不可解释的能量损耗。 ao就是在建模软件中预先计算了ssao,然后把ssao的结果写入纹理中,这样我们就不需要在管线中额外耗费性能去算ao了。
代码实现可以直接去(简介 - LearnOpenGL CN (learnopengl-cn.github.io)))查看。
HDR贴图
前面使用的是立体体贴图,但是现在主要使用的不是立方体贴图,这里先给一个加载立方体贴图的方法:
js
unsigned int loadHDR(const char* texturePath) {
stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
float* data = stbi_loadf(texturePath, &width, &height, &nrComponents, 0);
unsigned int hdrTexture;
if (data)
{
glGenTextures(1, &hdrTexture);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, width, height, 0, GL_RGB, GL_FLOAT, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
stbi_image_free(data);
}
else
{
std::cout << "Failed to load HDR image." << std::endl;
}
}
在加载之后,需要把它变成立方体贴图,最好的办法就是渲染一次,保存结果,先绑定帧缓冲为对应的六张纹理图片,下面给出代码:
js
unsigned int captureFBO, captureRBO;
glGenFramebuffers(1, &captureFBO);
glGenRenderbuffers(1, &captureRBO);
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO);
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);
}
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);
下面开始渲染:
js
unsigned int hdrTexture = loadHDR("D:/mitsuba3_project/Part3/0.hdr");
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))
};
shader_hdr.use();
shader_hdr.setInt("equirectangularMap", 0);
shader_hdr.setMatrix("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);
glViewport(0, 0, 512, 512);
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
shader_hdr.setMatrix("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindVertexArray(skyboxVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
在着色的时候有一些麻烦,首先立方体的片元世界坐标其实就代表了射线的方向,我们可以根据射线方向计算出该方向在HDR贴图上的采样位置,计算方式是u = arctan(z / x),v = arcsin(y);然而这样计算出来的u的范围是(0,2Π),v的范围是(0,Π),所以得给他们分别乘(1/2Π)和(1/Π):
js
////////////////////////顶点着色器////////////////////////
#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 vec2 invAtan = vec2(0.1591, 0.3183);
vec2 SampleSphericalMap(vec3 v)
{
vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
uv *= invAtan;
uv += 0.5;
return uv;
}
void main()
{
vec2 uv = SampleSphericalMap(normalize(localPos)); // make sure to normalize localPos
vec3 color = texture(equirectangularMap, uv).rgb;
FragColor = vec4(color, 1.0);
}
漫反射辐照IBL
对于环境贴图的效果想要反射到物体是一件非常麻烦的事情,环境贴图自己有一定的亮度,但是其数量和效果都很难定性的分析,只能对于环境贴图进行采样,先看一下渲染方程并且分析:
其中第一项是自发光,第二项是漫反射项,第三项是高光反射部分,首先先计算第二项漫反射部分:
使用的渲染公式是半球形公式,w是一个立体角,拆开成球坐标公式,则可以改写为:
现在需要对于这个半球面进行采样,方法是等间距采样,每一个求和在除以采样的数量,这是一种不是很严格的办法,但是也是目前的最优解:
现在需要写代码来实现这个过程,这里有:
js
#version 330 core
out vec4 FragColor;
in vec3 localPos;
uniform samplerCube environmentMap;
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)
{
//spherical to cartesian (in tangent space)
vec3 tangentSample = vec3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta));
//tangent space to world
vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N;
irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
nrSamples++;
}
}
irradiance = PI * irradiance * (1.0 / float(nrSamples));
FragColor = vec4(irradiance, 1.0);
}
"预卷积贴图"上的像素,记录的是当物体上某片元的法线方向指向该像素时,环境贴图对其漫反射项的贡献积分结果是多少,因此我们枚举到的"预卷积贴图"的片元坐标,就是到时候物体上片元的法线方向 接下来我们开始求黎曼和,按照公式将phi和theta分解成若干份,取样间距是sampleDelta,每次采样完phi和theta后可以根据球坐标公式求出具体的方位向量。然而这里有一个大坑:这个方位向量其实是切线空间的,我们需要将其转化到世界空间!我们来分析一下如何转化。
在切线空间中,切线、副切线、法线分别代表了(1,0,0),(0,1,0),(0,0,1)三个轴,假设我们的方位向量在切线空间下是(x,y,z),那么它可以写成(x * T切 + y * B切 + z * N切)的形式;
现在考虑T、B、N在世界空间中的方向,N的世界空间方向我们已经提前知道了(就是片元坐标),而T、B都不知道。但是事实上,半球是处处沿着法线对称的,所以T和B指向哪里是可以随意指定的,只要保证TBN互相正交垂直即可。那么我们就假设B是(0,1,0),然后用N和B叉乘得到T,再用T和N叉乘得到正交化的B即可。那么,方向向量的世界空间下的表示就是(x * T世 + y * B世界 + z * N世)了。这就是"vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N"这句代码所做的事情,将方位向量转换到世界空间。
接下来我们就沿着方位向量去采样环境贴图,然后把结果累加即可,最后除以(n1n2),当然其实nrSamples就是n1n2. 还是需要跑一下保存这个结果:
js
unsigned int hdrCubemap_convolution;
glGenTextures(1, &hdrCubemap_convolution);
glBindTexture(GL_TEXTURE_CUBE_MAP, hdrCubemap_convolution);
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);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 128, 128);
glViewport(0, 0, 128, 128);
shader_hdr_convolution.use();
shader_hdr_convolution.setInt("environmentMap", 0);
shader_hdr_convolution.setMatrix("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, hdrCubemap);
for (unsigned int i = 0; i < 6; ++i)
{
shader_hdr_convolution.setMatrix("view", captureViews[i]);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, hdrCubemap_convolution, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glBindVertexArray(skyboxVAO);
glDrawArrays(GL_TRIANGLES, 0, 36);
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
目前就是先做到这里,关于镜面的高光部分在第二部分进行解释。