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

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

相关推荐
charon87781 个月前
计算机图形学 | 动画模拟
计算机图形学·unreal engine·技术美术
李伟_Li慢慢1 个月前
微分立体角与辐射度量学
前端·计算机图形学
前端小煜2 个月前
使用naga插件将glsl代码翻译wgsl
计算机图形学
OhBonsai2 个月前
Shader 3d RayMarching8 光照
webgl·计算机图形学
OhBonsai2 个月前
Shader 3d RayMarching6 3D SDF造型
webgl·计算机图形学
OhBonsai2 个月前
Shader 3d RayMarching4 相机与鼠标控制
webgl·计算机图形学
五号线7832 个月前
Games101——光珊化——深度缓存——shading着色 1
计算机图形学
OhBonsai2 个月前
2D平面画出3D世界的Shader技术RayMarching的基本思路介绍
前端·webgl·计算机图形学
翼同学2 个月前
【计算机图形学 | 基于MFC三维图形开发】期末考试知识点汇总(上)
java·计算机图形学·期末考试
翼同学3 个月前
【计算机图形学 | 基于MFC三维图形开发】期末考试知识点汇总(下)
计算机·计算机图形学·期末考试·知识点汇总