PBR渲染其一

引言:

本文主要用来讲解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);

目前就是先做到这里,关于镜面的高光部分在第二部分进行解释。

相关推荐
刘好念4 小时前
[OpenGL]使用 Compute Shader 实现矩阵点乘
c++·计算机图形学·opengl·glsl
charlee443 天前
深度科普文:细数倾斜摄影数据的缺点
三维可视化·计算机图形学·倾斜摄影
刘好念3 天前
[OpenGL]使用TransformFeedback实现粒子效果
c++·计算机图形学·opengl
刘好念10 天前
[OpenGL] Transform feedback 介绍以及使用示例
c++·计算机图形学·opengl
刘好念1 个月前
[OpenGL]使用OpenGL+OIT实现透明效果
计算机图形学·opengl
刘好念2 个月前
[OpenGL]使用OpenGL实现硬阴影效果
c++·计算机图形学·opengl
黑猫很白2 个月前
计算机图形学-动画Animation-仿真物理模拟Simulation
计算机图形学
字节流动2 个月前
Vulkan 开发(三):Vulkan 物理设备
计算机图形学
字节流动2 个月前
Vulkan 开发(二):Vulkan 实例
计算机图形学