[图形学] 基于图像的照明:漫反射辐照度

reference: https://learnopengl.com/PBR/IBL/Diffuse-irradiance

        IBL或者基于图像的照明是一组照明物体的技术,它不像前一个教程中直接分析光,而是将周围环境视为一个大光源。这通常通过立方体环境贴图(取自现实世界或从3D场景中生成)来完成,这样我们就可以在我们的光照方程中直接使用它:将每个立方体贴图像素视为光发射器。通过这种方式,我们可以有效地捕获环境的全局照明和一般感觉,使物体更好的融入环境。

       由于基于图像的照明算法捕获某些(全局)环境的照明,我们认为它的输入是更精确的环境照明形式,甚至是全局照明的粗略近似。这使得IBL对于PBR很有意义,因为当我们考虑环境照明时,对象在物理上看起来更加正确。

        要开始将IBL引入我们的PBR系统,让我们再次快速了解反射方程:

        

        如前所述,我们的主要目标是求解半球Ω上所有入射光方向的积分。求解前一个教程中的积分很容易,因为我们事先已经知道了导致积分的确切的几个光方向。然而,这次来自周围环境的每个入射光方向都可能具有一些辐射,使得求解积分不那么简单了。这为求解积分提出了两个主要要求:

       · 给定任意方向向量wi,我们需要有方法来检测到场景的辐射

       · 求解积分需要快速、实时

        现在,第一个要求相对容易。之前我们已经有所暗示,表示环境或场景辐照度的一种方式是(处理过的)环境立方体贴图。给定这样的立方体贴图,我们可以将立方体贴图的每个纹素可视化为单个发光光源。通过使用任何方向向量对该立方体图进行采样,我们从该方向检测到场景的辐射。

        在任何方向矢量wi的情况下获得场景的辐射就像下面这样简单:

vec3 radiance = texture(_cubemapEnvironment, w_i).rgb;

        但是,求解积分需要我们从不仅一个方向采样环境映射,而在半球Ω上对所有可能的方向进行采样,这对于每个片段着色器而言太昂贵了。为了以更有效的方式求解积分,我们需要预处理或预计算。为此,我们必须深入研究反射方程:

        

        仔细研究反射方程,我们发现BRDF的漫反射Kd和镜面Ks项彼此独立,所以我们可以把积分分为两部分:

        

        通过将积分分为两部分,我们可以单独关注漫反射和镜面反射两项;本教程的重点是漫反射积分。

        仔细观察漫反射积分,我们发现漫反射lambert项是一个常数项(颜色c、折射率kd和π在积分上是常数)并且不依赖于任何积分变量。因此,我们将常数项移出漫反射积分。

          

        此时我们得到了一个仅取决于wi的积分(假设p位于环境映射的中心)。有了这些知识,我们就可以计算或预先计算一个新的立方体贴图,它通过卷积在每个样本方向(或纹素)中存储漫反射积分的结果。

        卷积考虑了数据集中的所有其它条目,对数据集中的每个条目应用一些计算;数据集是场景的辐射度或环境贴图。因此,对于立方体贴图中的每个样本方向,我们都会考虑半球Ω上的所有其它样本方向。

         为了对环境贴图进行卷积,我们通过对半球Ω上的大量方向进行离散采样并对其进行平均来求解每个输出样本方向的积分。我们朝着我们正在卷积的输出样本方向建立样本方向wi的半球。

         

         该预先计算的立方体映射,对于每个样本方向wo存储积分结果,可以被认为是场景中所有间接漫反射光的预先计算的总和,它会击中沿着方向wo对齐的一些表面。这样的立方体贴图被称为辐照度贴图,因为旋绕立方体贴图,我们就可以从任何方向wo直接采样场景(预计算的)辐照度。

         辐射方程也取决于位置p,我们假设它位于辐照度映射的中心。这意味着所有漫反射间接光必须来自单个环境映射,这可能会打破现实幻觉(特别是在室内)。渲染引擎通过在整个场景放置reflection probes来解决这个问题,其中每个reflection probes计算了周围它自身的辐照度映射。这样,位置p处的辐照度(和辐射)是其最接近的reflection probes之间的内插辐照度。目前,我们假设我们总是从中心对环境贴图进行采样,我们将在后面的教程中讨论reflection probes。

        下面是立方体环境贴图以及其生成的辐照度贴图的示例,平均了每个方向wo的辐照度。

         

         通过在每个立方体映射纹素里存储卷积结果(在方向wo上)辐照度映射有点像环境的平均和光照显示。从环境映射中采样任何方向将为我们提供某一特定方向的场景辐照度。

PBR和HDR

         我们已经在照明教程中简要地介绍了它:在PBR渲染管线中考虑场景的高动态范围非常重要。由于PBR的大部分输入都是基于实际物理属性的,因此将入射光值与其物理等效值紧密匹配是有意义的。无论我们对每种光的辐射通量进行猜测还是直接使用它们的直接物理等效物,一个简单的灯泡或太阳之间的差异都是重要的。如果我们不在HDR渲染环境中工作,则无法正确指定每个灯光的相对强度。

        因此,我们配合使用PBR和HDR,但这一切与基于图像的照明有什么关系?我们在之前的教程中已经看到,让PBR在HDR中工作相对容易。然而,为了实现基于图像的照明,我们将环境的间接光强度存储在环境立方体贴图的颜色值上,我们需要某种方式将照明的高动态范围存储到环境贴图中。

        我们一直使用的环境贴图就和立方体贴图(例如用作天空盒)一样,处于低动态范围(LDR)。我们直接使用来自某个面部图像的颜色值,范围处在0.0到1.0之间,并按原样处理它们。虽然这可能适用于视觉输出,但当它们作为物理输入参数时,它不会起作用。

辐射HDR的文件格式

        开始讨论辐射文件的格式。辐射文件格式(带有.hdr扩展名)存储一个完整的立方体映射,所有的6个面都存储浮点数据,允许任何人指定[0.0,1.0]范围之外的颜色值,使得光照具有正确的颜色强度。文件格式使用了一个技巧来存储每个浮点值,不是每个通道的32位值,但每个通道使用颜色的alpha通道作为指数存储8位(这确实会导致精度损失)。这非常有效,但需要解析程序将每种颜色重新转换为它们的浮点等价数。

        有很多可以从slBL中免费获得的辐射HDR环境映射,我们可以在下面看到一个示例:

        

         这张图像可能与预期完全不同,因为图像显示失真,并且未显示我们之前看到的环境贴图的6个单独立方体贴图面中的任何一个。此环境贴图从球体投影到了平面上,这样我们就可以更轻松地将环境存储到单个图像中的equirectangular map。这确实带来了一些问题,因为大多数视觉分辨率存储在水平视图方向,而较少保留在底部和顶部方向。在大多数情况下,这是一个不错的折中方案,因为几乎对于所有的渲染器,我们都可以在水平观察方向上找到大部分有趣的照明和环境。

HDR和stb_image.h

        加载辐射HDR图像需要一些文件格式的知识,这不是很难,但仍然比较麻烦。幸运的是,有一个流行的库stb_image.h支持将辐射HDR图像直接加载为浮点数数组,这完全符合我们的需要。随着stb_image添加到项目中,加载HDR图像现在像如下这样简单:

#include "stb_image.h"
[...]

stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
float* data = stbi_loadf("newport_loft.hdr",&width, &height, &nrComponents, 0);
unsigned int hdrTexture;
if(data)
{
    glGenTexture(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_CLMAP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLMAP_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;
}

        stb_image.h自动将HDR值映射到浮点值列表:默认情况下,每个通道32位,每种颜色3个通道。这就是我们需要将equirectangular HDR环境贴图存储到2D浮点纹理中的所有内容。

从Equirectangular到Cubemap

        我们可以直接使用equirectangular映射进行环境查找,但这些操作相对昂贵。在这种情况下,直接做立方体贴图采样的性能会更高。因此,在本教程中,我们首先将equirectangular图像转换为立方体贴图以进行进一步处理。请注意,在此过程中,我们还将展示如何对equirectangular映射进行采样,把它看作一个3D环境地图一样,在这种情况下,你可以自由选择你喜欢的任何方案。

         要将equirectangular图像转换为立方体贴图,我们需要渲染一个单位立方体,并从内部投影所有立方体面上的equirectangular映射,并将每个立方体边的6个图像作为立方体贴图面。此立方体的顶点着色器知识按照原样渲染立方体,并将局部位置传递给片元着色器:

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

        对于片元着色器,我们为立方体的每个部分着色,就像我们将立方体贴图整齐地折叠到立方体的每个边一样。为了实现这一点,我们将片元的样本方向从立方体的局部位置进行插值,然后使用此方向向量和一些三角技术对equirectangular map进行采样,就好像它是立方体映射本身一样。我们直接将结果存储到cube-face的片元中,这应该是我们需要做的:

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

        如果在给定HDR equirectangular map的情况下在场景的中心渲染立方体,将得到如下所示的内容:

        

        这表明我们已经有效地将equirectangular图像映射到了立方体形状,但还没有将原HDR图像转换为立方体贴图纹理。为了实现这一点,我们必须将相同的立方体渲染6次,查看立方体每个单独的面,同时使用framebuffer对象记录可视结果:

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

        当然,我们还会生成相应的立方体贴图,为其6个面中的每个面预先分配内存:

unsigned int envCubemap;
glGenTextures(1, &envCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
for(unsigned int i = 0;i < 6; ++i)
{
    // note that we store each face with 16 bit floating point values
    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);

        剩下的要做的就是将立方体2D纹理采样到立方体贴图面上:

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

// convert HDR equirectangular environment map to cubemap equivalent
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap", 0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);

glViewport(0, 0, 512, 512); 
// don't forget to configure the viewport to the capture dimensions.
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for( unsigned int i = 0; i < 6; ++i)
{
    equirectangularToCubemapShader.setMat4("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);

    renderCube(); // renders a 1x1 cube
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

       我们采用帧缓冲的颜色附件,并为立方体贴图的每个面切换纹理目标,直接将场景渲染到立方体贴图的一个面上。这一例程完成后,立方体贴图envCubemap就应该是原始HDR图像的立方体环境贴图版本。

        让我们编写一个非常简单的天空盒着色器来测试立方体贴图,来显示我们周围的立方体贴图:

#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)); // remove translation from the view matrix
    vec4 clipPos = projection * rotView * vec4(localPos, 1.0);

    gl_Position = clipPos.xyww;
}

        注意xyww这里的技巧,确保渲染的立方体片元的深度值总是不大于1.0,如cubemap教程所描述的。注意我们需要将深度比较函数改为GL_LEQUAL:

glDepthFunc(GL_LEQUAL);

        然后,片元着色器就可以使用立方体的局部片元位置直接采样立方体环境映射:

#version 330 core
out vec4 FragColor;

in vec3 localPos;

uniform samplerCube environmentMap;

void main()
{
    vec3 envColor = texture(environmentMap, localPos).rgb;

    envColor = envColor / (envColor + vec3(1.0));
    envColor = pow(envColor, vec3(1.0/2.2));
    
    FragColor = vec4(envColor, 1.0);
}

        我们使用插值的顶点立方体位置对环境贴图进行采样,这些位置对应于要采样的正确方向向量。相机的平移部分被忽略,意味着在多维数据集上渲染此着色器会将该环境贴图作为非移动的背景。另外,需注意,当我们将环境贴图的HDR值直接输出到默认的LDR帧缓冲区时,我们需要正确的对颜色值进行色调映射。此外,默认情况下,几乎所有HDR映射都处在线性色彩空间中,因此我们需要在写入默认帧缓冲区之前应用gamma校正。

        现在,在先前渲染的球体上渲染采样环境贴图效果如下所示:

        

        我们花了不少时间来设置,也成功地读取了HDR环境贴图,将其从equirectangular映射转换为立方体贴图,并将HDR立方体贴图作为天空盒渲染到场景中。此外,我们设置了一个小系统来渲染立方体贴图的所有六个面,我们还需要它们来卷积环境映射。可在此处找到整个转化过程的源码。

Cubemap卷积

        如教程的开头所述,我们的主要目标是以立方体环境映射的形式为得到场景的辐照度求解所有漫反射间接光照的积分。我们知道通过在方向wi上采样HDR环境映射,我们可以在特定方向上获得场景L(p,wi)的辐射。为了求解积分,我们必须从半球Ω内的所有可能方向对每个片元采样场景的辐射。

        然而,我们不可能从Ω的每个可能方向来对环境的光照进行采样,因为可能的方向的数量在理论上是无限的。但是,我们可以通过使用有限数量的方向或样本来近似方向的数量,使用均匀的间隔或从半球内随机取得以获得相当精确的辐照度近似,以有效地求解积分。

        但是,对于每个片段实时执行此操作仍然太昂贵,因为样本的数量仍然要非常大才能获得不错的结果,因此我们希望预先计算它。由于半球的方向决定我们取得辐照度的位置,我们可以预先计算每个可能的半球方向的辐照度,这些方向围绕所有传出方向wo:

        

        给定任意方向向量wi,我们可以对预先计算的辐照度映射进行采样,以从方向wi检索到总漫反射辐照度。为了确定片元表面的间接漫反射辐照的数量,我们从半球的整个辐照度中检索围绕其表面法线的总辐照度,获得场景的辐照度就像如下这么简单:

vec3 irradiance = texture(inrradianceMap, N);

        现在,为了生成辐照度映射,我们需要将环境的光照转换为立方体贴图。假设对于每个片元,表面的半球沿着法向量N方向,对立方体映射进行卷积等于计算沿着N定向的半球Ω中的每个方向的wi总平均辐射率。

        

        值得庆幸的是,我们现在可以直接获取转换后的立方体贴图,在片元着色器中对其进行卷积,并使用向所有6个面方向呈现的帧缓冲区将其结果获取到新的立方体贴图中。由于我们已经设置了将equirectangular环境贴图转换为立方体贴图,我们可以采用完全相同的方法,但使用不同的片元着色器:

#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform samplerCube environmentMap;

const float PI = 3.14159265359;

void main()
{
    // the sample direction equals the hemisphere's orientation
    vec3 normal = normalize(localPos);

    vec3 irradiance = vec3(0.0);

    [...] // convolution code

    FragColor = vec4(irradiance, 1.0); 
}

        环境映射是从立方体HDR环境映射转换而来的HDR立方体贴图。

        有许多方法可以对环境贴图进行卷积,而在本教程中,我们将为沿着半球Ω的每个立方体贴图纹素生成固定数量的样本矢量,这些样本矢量围绕样本方向并平均结果。固定量的样本矢量将均匀的分布在半球内部。请注意,积分是连续函数,在给定固定量的样本矢量的情况下离散的采样其函数将会是近似值。我们使用的样本矢量越多,我们越接近积分结果。

        反射方程的积分∫ 围绕立体角旋转,我们将积分转换为其等效球面坐标θ和ϕ,而不是在立体角dw上积分。

        

        我们使用极化方位角ϕ在0到2π之间的半球环周围进行采样,并使用0到π/2之间的倾角θ对半球的增加环进行采样。这将为我们提供新的反射积分:

        

        求解积分需要我们在半球Ω内采样固定数量的离散样本并对其结果求平均。这将积分转换为如下离散版本,基于Riemann 和分别在每个球坐标上给出n1和n2两个离散样本:

        

        当我们离散的对两个球面值进行采样时,每个样本将近似或平均半球上的区域,如上图所示。注意由于球形的一般性质,当样本区域朝向中心顶部聚合时,半球的离散样本区域越小,角度θ越高。为了弥补较小的区域,我们通过使用sinθ来缩放其贡献。

        给定每个片元调用的积分球面坐标对半球进行离散采样转换的代码:

vec3 irradiance = vec3(0.0);

vec3 up = vec3(0.0, 1.0, 0.0);
vec3 right = cross(up, normal);
p = 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));

        我们指定一个固定的sampleDelta delta值来遍历半球,减小或增加样本增量将分别增加或减少准确度。

        在两个循环内,我们采用球面坐标将它们转换为3D笛卡尔样本矢量,将样本从切线转换为世界控件,并使用此样本矢量直接对HDR环境贴图进行采样。我们将每个样本结果添加到辐照度,最后我们除以采样的总数,得到平均采样辐照度。请注意,我们将采样的颜色值按照cos(theta)缩放,因为较大角度的光较弱,而用sin(theta)缩放较高半球区域的较小样本区域。

        现在剩下要做的就是设置OPENGL渲染代码,以便我们可以卷积早期捕获的envCubemap。首先我们创建辐照度立方体贴图,这只需要在渲染循环之前执行一次:

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

       由于辐照度映射均匀地平均所有周围辐射,因此它不具有大量高频细节,我们可以用低分辨率(32x32)存储映射,并让OpenGL的线性滤波完成大部分工作。接下来,我们将帧缓冲区重新缩放到新的分辨率:

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); // don't forget to configure the viewport to the capture dimensions.
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和间接辐照度照明

        辐照度映射表示从所有周围的间接光累积的反射率积分的漫反射部分。看到的光不是来自任何直接光源,而是来自周围环境,我们将漫反射和镜面间接照明视为环境照明,取代了我们之前设定的常数项。

        首先,要将预先计算的辐照度映射添加为立方体采样器:

uniform samplerCube irradianceMap;

        给定保留所有场景的间接漫反射光的辐照度映射,采样影响片段的辐照度就像给定表面法线的单个纹理样本一样简单:

// vec3 ambient = vec3(0.03);
vec3 ambient = texture(irradianceMap, N).rgb;

        然而,由于间接照明包含漫反射和镜面反射部分,正如我们从反射方程的分割版本中看到的那样,我们需要相应的对漫反射部分进行加权。与我们在前一教程中所做的类似,我们使用菲涅尔方程来确定表面的间接反射率,我们从中得到折射率或漫反射率:

vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse    = irradiance * albedo;
vec3 ambient    = (kD * diffuse) * ao; 

        由于环境光来自半球内围绕法线N的所有方向,没有单一的中间向量来确定菲涅尔反射。为了模拟菲涅尔,我们从法线和视图矢量之间的角度计算菲涅尔。然而,早些时候,我们使用受表面粗糙度影响的微平面中间向量作为菲涅尔的输入。由于我们目前没有考虑任何粗糙度u,因此表面的反射率总是会相对较高。间接光遵循直射光的相同属性,因此我们期望较粗糙的表面在表面边缘上反射较弱。由于我们没有考虑表面的粗糙度,间接菲涅尔反射强度在粗糙的非金属表面上看起来是这样:

         

        我们可以在Fresnel-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; 

       如你所见,基于图像的实际照明计算非常简单,只需要一个立方体贴图纹理查找,大部分工作是将环境映射预先计算或卷积成辐照度映射。

       如果我们从照明教程中获取初始场景,其中每个球体具有垂直增加的金属和水平增加的粗糙度值,并添加基于漫反射图像的照明,它看起来像是这样:

        

        它看起来仍然有些奇怪,因为更多的金属球体需要某种形式的反射使其看起来像金属表面(因为金属表面不反射漫反射光),此刻目前只是来自点光源。尽管如此,球体在环境中感觉更加真实,因为表面已经会相应地响应环境的环境照明。

        可在此处找到完整的源代码。接下来的教程中,我们将添加反射率,我们将通过剩余的镜面组成部分真正看到PBR的效果。

猜你喜欢

转载自blog.csdn.net/ZJU_fish1996/article/details/88776544