Learn OpenGL 32 IBL

漫反射辐照度

基于图像的光照(Image based lighting, IBL)是一类光照技术的集合。其光源不是如前一节教程中描述的可分解的直接光源,而是将周围环境整体视为一个大光源。IBL 通常使用(取自现实世界或从3D场景生成的)环境立方体贴图 (Cubemap) ,我们可以将立方体贴图的每个像素视为光源,在渲染方程中直接使用它。这种方式可以有效地捕捉环境的全局光照和氛围,使物体更好地融入其环境。

由于基于图像的光照算法会捕捉部分甚至全部的环境光照,通常认为它是一种更精确的环境光照输入格式,甚至也可以说是一种全局光照的粗略近似。基于此特性,IBL 对 PBR 很有意义,因为当我们将环境光纳入计算之后,物体在物理方面看起来会更加准确。

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

如前所述,我们的主要目标是计算半球 ΩΩ 上所有入射光方向 wi的积分。解决上一节教程中的积分非常简单,因为我们事先已经知道了对积分有贡献的、若干精确的光线方向 wi 。然而这次,来自周围环境的每个方向 wi 的入射光都可能具有一些辐射度,使得解决积分变得不那么简单。这为解决积分提出了两个要求:

  • 给定任何方向向量 wi ,我们需要一些方法来获取这个方向上场景的辐射度。
  • 解决积分需要快速且实时。

现在看,第一个要求相对容易些。我们已经有了一些思路:表示环境或场景辐照度的一种方式是(预处理过的)环境立方体贴图,给定这样的立方体贴图,我们可以将立方体贴图的每个纹素视为一个光源。使用一个方向向量 wi 对此立方体贴图进行采样,我们就可以获取该方向上的场景辐照度。

如此,给定方向向量 wi ,获取此方向上场景辐射度的方法就简化为:

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

为了以更有效的方式解决积分,我们需要对其大部分结果进行预处理——或称预计算。为此,我们必须深入研究反射方程:

仔细研究反射方程,我们发现 BRDF 的漫反射 kd 和镜面 ks 项是相互独立的,我们可以将积分分成两部分:

通过将积分分成两部分,我们可以分开研究漫反射和镜面反射部分,本教程的重点是漫反射积分部分。

仔细观察漫反射积分,我们发现漫反射兰伯特项是一个常数项(颜色 c 、折射率 kd 和 π 在整个积分是常数),不依赖于任何积分变量。基于此,我们可以将常数项移出漫反射积分:

这给了我们一个只依赖于 wi 的积分(假设 p 位于环境贴图的中心)。有了这些知识,我们就可以计算或预计算一个新的立方体贴图,它在每个采样方向——也就是纹素——中存储漫反射积分的结果,这些结果是通过卷积计算出来的。

卷积的特性是,对数据集中的一个条目做一些计算时,要考虑到数据集中的所有其他条目。这里的数据集就是场景的辐射度或环境贴图。因此,要对立方体贴图中的每个采样方向做计算,我们都会考虑半球 ΩΩ 上的所有其他采样方向。

为了对环境贴图进行卷积,我们通过对半球 ΩΩ 上的大量方向进行离散采样并对其辐射度取平均值,来计算每个输出采样方向 wo 的积分。用来采样方向 wi的半球,要面向卷积的输出采样方向 wo 。

这个预计算的立方体贴图,在每个采样方向 wo上存储其积分结果,可以理解为场景中所有能够击中面向 wo 的表面的间接漫反射光的预计算总和。这样的立方体贴图被称为辐照度图,因为经过卷积计算的立方体贴图能让我们从任何方向有效地直接采样场景(预计算好的)辐照度。

辐射方程也依赖了位置 p ,不过这里我们假设它位于辐照度图的中心。这就意味着所有漫反射间接光只能来自同一个环境贴图,这样可能会破坏现实感(特别是在室内)。渲染引擎通过在场景中放置多个反射探针来解决此问题,每个反射探针单独预计算其周围环境的辐照度图。这样,位置 p 处的辐照度(以及辐射度)是取离其最近的反射探针之间的辐照度(辐射度)内插值。目前,我们假设总是从中心采样环境贴图,把反射探针的讨论留给后面的教程。

下面是一个环境立方体贴图及其生成的辐照度图的示例(由 Wave 引擎提供),每个方向 wo 的场景辐射度取平均值。

由于立方体贴图每个纹素中存储了( wo方向的)卷积结果,辐照度图看起来有点像环境的平均颜色或光照图。使用任何一个向量对立方体贴图进行采样,就可以获取该方向上的场景辐照度。

PBR 和 HDR

我们在光照教程中简单提到过:在 PBR 渲染管线中考虑高动态范围(High Dynamic Range, HDR)的场景光照非常重要。由于 PBR 的大部分输入基于实际物理属性和测量,因此为入射光值找到其物理等效值是很重要的。无论我们是对光线的辐射通量进行研究性猜测,还是使用它们的直接物理等效值,诸如一个简单灯泡和太阳之间的这种差异都是很重要的,如果不在 HDR 渲染环境中工作,就无法正确指定每个光的相对强度。

因此,PBR 和 HDR 需要密切合作,但这些与基于图像的光照有什么关系?我们在之前的教程中已经看到,让 PBR 在 HDR 下工作还比较容易。然而,回想一下基于图像的光照,我们将环境的间接光强度建立在环境立方体贴图的颜色值上,我们需要某种方式将光照的高动态范围存储到环境贴图中。

我们一直使用的环境贴图是以立方体贴图形式储存——如同一个天空盒——属于低动态范围(Low Dynamic Range, LDR)。我们直接使用各个面的图像的颜色值,其范围介于 0.0 和 1.0 之间,计算过程也是照值处理。这样虽然可能适合视觉输出,但作为物理输入参数,没有什么用处。

辐射度的 HDR 文件格式

谈及辐射度的文件格式,辐射度文件的格式(扩展名为 .hdr)存储了一张完整的立方体贴图,所有六个面数据都是浮点数,允许指定 0.0 到 1.0 范围之外的颜色值,以使光线具有正确的颜色强度。这个文件格式使用了一个聪明的技巧来存储每个浮点值:它并非直接存储每个通道的 32 位数据,而是每个通道存储 8 位,再以 alpha 通道存放指数——虽然确实会导致精度损失,但是非常有效率,不过需要解析程序将每种颜色重新转换为它们的浮点数等效值。

有很多可以免费获取的辐射度 HDR 环境贴图,下面是一个示例:

可能与您期望的完全不同,因为图像非常扭曲,并且没有我们之前看到的环境贴图的六个立方体贴图面。这张环境贴图是从球体投影到平面上,以使我们可以轻松地将环境信息存储到一张等距柱状投影图(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)
{
    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;
}  

stb_image.h 自动将 HDR 值映射到一个浮点数列表:默认情况下,每个通道32位,每个颜色 3 个通道。我们要将等距柱状投影 HDR 环境贴图转存到 2D 浮点纹理中,这就是所要做的全部工作。

从等距柱状投影到立方体贴图

当然也可以直接使用等距柱状投影图获取环境信息,但是这些操作还是显得相对昂贵,在这种情况下,直接采样立方体贴图的性能更高。因此,在本教程中,我们首先将等距柱状投影图转换为立方体贴图以备进一步处理。请注意,在此过程中,我们还将展示如何对等距柱状格式的投影图采样,如同采样 3D 环境贴图一样,您可以自由选择您喜欢的任何解决方案。

要将等距柱状投影图转换为立方体贴图,我们需要渲染一个(单位)立方体,并从内部将等距柱状图投影到立方体的每个面,并将立方体的六个面的图像构造成立方体贴图。此立方体的顶点着色器只是按原样渲染立方体,并将其局部坐标作为 3D 采样向量传递给片段着色器:

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

如果给定HDR等距柱状投影图,在场景的中心渲染一个立方体,将得到如下所示的内容:

这表明我们有效地将等距柱状投影图映射到了立方体,但我们还需要将源HDR图像转换为立方体贴图纹理。为了实现这一点,我们必须对同一个立方体渲染六次,每次面对立方体的一个面,并用帧缓冲对象记录其结果:

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)
{
    // 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 纹理捕捉到立方体贴图的面上。

之前在帧缓冲点阴影教程中讨论过的代码细节,我就不再次详细说明,实际过程可以概括为:面向立方体六个面设置六个不同的视图矩阵,给定投影矩阵的 fov 为 90 度以捕捉整个面,并渲染立方体六次,将结果存储在浮点帧缓冲中:

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,即最大深度,如立方体贴图教程中所述。注意我们需要将深度比较函数更改为 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 图都处于线性颜色空间中,因此我们需要在写入默认帧缓冲之前应用伽马校正

现在,在之前渲染的球体上渲染环境贴图,效果应该如下图:

 好的…我们用了相当多的设置终于来到了这里,我们设法成功地读取了 HDR 环境贴图,将它从等距柱状投影图转换为立方体贴图,并将 HDR 立方体贴图作为天空盒渲染到了场景中。此外,我们设置了一个小系统来渲染立方体贴图的所有六个面,我们在计算环境贴图卷积时还会需要它。

立方体贴图的卷积

如本节教程开头所述,我们的主要目标是计算所有间接漫反射光的积分,其中光照的辐照度以环境立方体贴图的形式给出。我们已经知道,在方向 wi 上采样 HDR 环境贴图,可以获得场景在此方向上的辐射度 L(p,wi)。虽然如此,要解决积分,我们仍然不能仅从一个方向对环境贴图采样,而要从半球 ΩΩ 上所有可能的方向进行采样,这对于片段着色器而言还是过于昂贵。

然而,计算上又不可能从 ΩΩ 的每个可能的方向采样环境光照,理论上可能的方向数量是无限的。不过我们可以对有限数量的方向采样以近似求解,在半球内均匀间隔或随机取方向可以获得一个相当精确的辐照度近似值,从而离散地计算积分 ∫∫ 。

然而,对于每个片段实时执行此操作仍然太昂贵,因为仍然需要非常大的样本数量才能获得不错的结果,因此我们希望可以预计算。既然半球的朝向决定了我们捕捉辐照度的位置,我们可以预先计算每个可能的半球朝向的辐照度,这些半球朝向涵盖了所有可能的出射方向 wo:

给定任何方向向量 wi ,我们可以对预计算的辐照度图采样以获取方向 wi 的总漫反射辐照度。为了确定片段上间接漫反射光的数量(辐照度),我们获取以表面法线为中心的半球的总辐照度。获取场景辐照度的方法就简化为:

vec3 irradiance = texture(irradianceMap, N);

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

值得庆幸的是,本节教程中所有繁琐的设置并非毫无用处,因为我们现在可以直接获取转换后的立方体贴图,在片段着色器中对其进行卷积,渲染所有六个面,将其结果用帧缓冲捕捉到新的立方体贴图中。之前已经将等距柱状投影图转换为立方体贴图,这次我们可以采用完全相同的方法,但使用不同的片段着色器:

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

environmentMap 是从等距柱状投影图转换而来的 HDR 立方体贴图。 有很多方法可以对环境贴图进行卷积,但是对于本教程,我们的方法是:对于立方体贴图的每个纹素,在纹素所代表的方向的半球 Ω 内生成固定数量的采样向量,并对采样结果取平均值。数量固定的采样向量将均匀地分布在半球内部。注意,积分是连续函数,在采样向量数量固定的情况下离散地采样只是一种近似计算方法,我们采样的向量越多,就越接近正确的结果。 反射方程的积分 ∫∫ 是围绕立体角 dw旋转,而这个立体角相当难以处理。为了避免对难处理的立体角求积分,我们使用球坐标 θ 和 ϕ来代替立体角。

对于围绕半球大圆的航向角 ϕ ,我们在 0 到 2π 内采样,而从半球顶点出发的倾斜角 θ ,采样范围是 0 到 1/2π 。于是我们更新一下反射积分方程:

求解积分需要我们在半球 ΩΩ 内采集固定数量的离散样本并对其结果求平均值。分别给每个球坐标轴指定离散样本数量 n1 和 n2 以求其黎曼和,积分式会转换为以下离散版本:

当我们离散地对两个球坐标轴进行采样时,每个采样近似代表了半球上的一小块区域,如上图所示。注意,由于球的一般性质,当采样区域朝向中心顶部会聚时,天顶角 θ 变高,半球的离散采样区域变小。为了平衡较小的区域贡献度,我们使用 sin 来权衡区域贡献度,这就是多出来的 sin 的作用。

给定每个片段的积分球坐标,对半球进行离散采样,过程代码如下:

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

我们以一个固定的 sampleDelta 增量值遍历半球,减小(或增加)这个增量将会增加(或减少)精确度。

在两层循环内,我们获取一个球面坐标并将它们转换为 3D 直角坐标向量,将向量从切线空间转换为世界空间,并使用此向量直接采样 HDR 环境贴图。我们将每个采样结果加到 irradiance,最后除以采样的总数,得到平均采样辐照度。请注意,我们将采样的颜色值乘以系数 cos(θ) ,因为较大角度的光较弱,而系数 sin(θ) 则用于权衡较高半球区域的较小采样区域的贡献度。

现在剩下要做的就是设置 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);  

现在,完成这个流程之后,我们应该得到了一个预计算好的辐照度图,可以直接将其用于IBL 计算。为了查看我们是否成功地对环境贴图进行了卷积,让我们将天空盒的环境采样贴图替换为辐照度贴图:

如果它看起来像模糊的环境贴图,说明您已经成功地对环境贴图进行了卷积。

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 的所有方向,因此没有一个确定的半向量来计算菲涅耳效应。为了模拟菲涅耳效应,我们用法线和视线之间的夹角计算菲涅耳系数。然而,之前我们是以受粗糙度影响的微表面半向量作为菲涅耳公式的输入,但我们目前没有考虑任何粗糙度,表面的反射率总是会相对较高。间接光和直射光遵循相同的属性,因此我们期望较粗糙的表面在边缘反射较弱。由于我们没有考虑表面的粗糙度,间接菲涅耳反射在粗糙非金属表面上看起来有点过强(为了演示目的略微夸大):

我们可以通过在 Sébastien Lagarde 提出的 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 的力量。

镜面反射 IBL

你会注意到 Cook-Torrance 镜面部分(乘以ks)在整个积分上不是常数,不仅受入射光方向影响,受视角影响。如果试图解算所有入射光方向加所有可能的视角方向的积分,二者组合数会极其庞大,实时计算太昂贵。Epic Games 提出了一个解决方案,他们预计算镜面部分的卷积,为实时计算作了一些妥协,这种方案被称为分割求和近似法(split sum approximation)。 分割求和近似将方程的镜面部分分割成两个独立的部分,我们可以单独求卷积,然后在 PBR 着色器中求和,以用于间接镜面反射部分 IBL。分割求和近似法类似于我们之前求辐照图预卷积的方法,需要 HDR 环境贴图作为其卷积输入。 

由于与辐照度卷积相同的(性能)原因,我们无法以合理的性能实时求解积分的镜面反射部分。因此,我们最好预计算这个积分,以得到像镜面 IBL 贴图这样的东西,用片段的法线对这张图采样并计算。但是,有一个地方有点棘手:我们能够预计算辐照度图,是因为其积分仅依赖于ωi,并且可以将漫反射反射率常数项移出积分,但这一次,积分不仅仅取决于ωi,从 BRDF 可以看出:

这次积分还依赖ωo,我们无法用两个方向向量采样预计算的立方体图。如前一个教程中所述,位置p与此处无关。在实时状态下,对每种可能的ωi和ωo的组合预计算该积分是不可行的。 Epic Games 的分割求和近似法将预计算分成两个单独的部分求解,再将两部分组合起来得到后文给出的预计算结果。分割求和近似法将镜面反射积分拆成两个独立的积分:

卷积的第一部分被称为预滤波环境贴图,它类似于辐照度图,是预先计算的环境卷积贴图,但这次考虑了粗糙度。因为随着粗糙度的增加,参与环境贴图卷积的采样向量会更分散,导致反射更模糊,所以对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的 mipmap 中。例如,预过滤的环境贴图在其 5 个 mipmap 级别中存储 5 个不同粗糙度值的预卷积结果,如下图所示:

我们使用 Cook-Torrance BRDF 的法线分布函数(NDF)生成采样向量及其散射强度,该函数将法线和视角方向作为输入。由于我们在卷积环境贴图时事先不知道视角方向,因此 Epic Games 假设视角方向——也就是镜面反射方向——总是等于输出采样方向ωo,以作进一步近似。翻译成代码如下:

vec3 N = normalize(w_o);
vec3 R = N;
vec3 V = R;

这样,预过滤的环境卷积就不需要关心视角方向了。这意味着当从如下图的角度观察表面的镜面反射时,得到的掠角镜面反射效果不是很好(图片来自文章《Moving Frostbite to PBR》)。然而,通常可以认为这是一个体面的妥协:

等式的第二部分等于镜面反射积分的 BRDF 部分。如果我们假设每个方向的入射辐射度都是白色的(因此L(p,x)=1.0 ),就可以在给定粗糙度、光线 ωi 法线 n 夹角 n⋅ωi的情况下,预计算 BRDF 的响应结果。Epic Games 将预计算好的 BRDF 对每个粗糙度和入射角的组合的响应结果存储在一张 2D 查找纹理(LUT)上,称为BRDF积分贴图。2D 查找纹理存储是菲涅耳响应的系数(R 通道)和偏差值(G 通道),它为我们提供了分割版镜面反射积分的第二个部分:

生成查找纹理的时候,我们以 BRDF 的输入n⋅ωi(范围在 0.0 和 1.0 之间)作为横坐标,以粗糙度作为纵坐标。有了此 BRDF 积分贴图和预过滤的环境贴图,我们就可以将两者结合起来,以获得镜面反射积分的结果:

float lod             = getMipLevelFromRoughness(roughness);
vec3 prefilteredColor = textureCubeLod(PrefilteredEnvMap, refVec, lod);
vec2 envBRDF          = texture2D(BRDFIntegrationMap, vec2(NdotV, roughness)).xy;
vec3 indirectSpecular = prefilteredColor * (F * envBRDF.x + envBRDF.y) 

至此,你应该对 Epic Games 的分割求和近似法的原理,以及它如何近似求解反射方程的间接镜面反射部分有了一些基本印象。让我们现在尝试一下自己构建预卷积部分。

预滤波HDR环境贴图

预滤波环境贴图的方法与我们对辐射度贴图求卷积的方法非常相似。对于卷积的每个粗糙度级别,我们将按顺序把模糊后的结果存储在预滤波贴图的 mipmap 中。 首先,我们需要生成一个新的立方体贴图来保存预过滤的环境贴图数据。为了确保为其 mip 级别分配足够的内存,一个简单方法是调用 glGenerateMipmap。

unsigned int prefilterMap;
glGenTextures(1, &prefilterMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, prefilterMap);
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_MIPMAP_LINEAR); 
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

注意,因为我们计划采样 prefilterMap 的 mipmap,所以需要确保将其缩小过滤器设置为 GL_LINEAR_MIPMAP_LINEAR 以启用三线性过滤。它存储的是预滤波的镜面反射,基础 mip 级别的分辨率是每面 128×128,对于大多数反射来说可能已经足够了,但如果场景里有大量光滑材料(想想汽车上的反射),可能需要提高分辨率。

在上一节教程中,我们使用球面坐标生成均匀分布在半球 ΩΩ 上的采样向量,以对环境贴图进行卷积。虽然这个方法非常适用于辐照度,但对于镜面反射效果较差。镜面反射依赖于表面的粗糙度,反射光线可能比较松散,也可能比较紧密,但是一定会围绕着反射向量r�,除非表面极度粗糙:

所有可能出射的反射光构成的形状称为镜面波瓣。随着粗糙度的增加,镜面波瓣的大小增加;随着入射光方向不同,形状会发生变化。因此,镜面波瓣的形状高度依赖于材质。 在微表面模型里给定入射光方向,则镜面波瓣指向微平面的半向量的反射方向。考虑到大多数光线最终会反射到一个基于半向量的镜面波瓣内,采样时以类似的方式选取采样向量是有意义的,因为大部分其余的向量都被浪费掉了,这个过程称为重要性采样。

蒙特卡洛积分和重要性采样

为了充分理解重要性采样,我们首先要了解一种数学结构,称为蒙特卡洛积分。蒙特卡洛积分主要是统计和概率理论的组合。蒙特卡洛可以帮助我们离散地解决人口统计问题,而不必考虑所有人。

例如,假设您想要计算一个国家所有公民的平均身高。为了得到结果,你可以测量每个公民并对他们的身高求平均,这样会得到你需要的确切答案。但是,由于大多数国家人海茫茫,这个方法不现实:需要花费太多精力和时间。

另一种方法是选择一个小得多的完全随机(无偏)的人口子集,测量他们的身高并对结果求平均。可能只测量 100 人,虽然答案并非绝对精确,但会得到一个相对接近真相的答案,这个理论被称作大数定律。我们的想法是,如果从总人口中测量一组较小的真正随机样本的N,结果将相对接近真实答案,并随着样本数 N 的增加而愈加接近。

蒙特卡罗积分建立在大数定律的基础上,并采用相同的方法来求解积分。不为所有可能的(理论上是无限的)样本值 x 求解积分,而是简单地从总体中随机挑选样本 N 生成采样值并求平均。随着 N 的增加,我们的结果会越来越接近积分的精确结果:

为了求解这个积分,我们在 a 到 b 上采样 N 个随机样本,将它们加在一起并除以样本总数来取平均。pdf 代表概率密度函数 (probability density function),它的含义是特定样本在整个样本集上发生的概率。例如,人口身高的 pdf 看起来应该像这样:

从该图中我们可以看出,如果我们对人口任意随机采样,那么挑选身高为 1.70 的人口样本的可能性更高,而样本身高为 1.50 的概率较低。

当涉及蒙特卡洛积分时,某些样本可能比其他样本具有更高的生成概率。这就是为什么对于任何一般的蒙特卡洛估计,我们都会根据 pdf 将采样值除以或乘以采样概率。到目前为止,我们每次需要估算积分的时候,生成的样本都是均匀分布的,概率完全相等。到目前为止,我们的估计是无偏的,这意味着随着样本数量的不断增加,我们最终将收敛到积分的精确解。

但是,某些蒙特卡洛估算是有偏的,这意味着生成的样本并不是完全随机的,而是集中于特定的值或方向。这些有偏的蒙特卡洛估算具有更快的收敛速度,它们会以更快的速度收敛到精确解,但是由于其有偏性,可能永远不会收敛到精确解。通常来说,这是一个可以接受的折衷方案,尤其是在计算机图形学中。因为只要结果在视觉上可以接受,解决方案的精确性就不太重要。下文我们将会提到一种(有偏的)重要性采样,其生成的样本偏向特定的方向,在这种情况下,我们会将每个样本乘以或除以相应的 pdf 再求和。

蒙特卡洛积分在计算机图形学中非常普遍,因为它是一种以高效的离散方式对连续的积分求近似而且非常直观的方法:对任何面积/体积进行采样——例如半球 Ω ——在该面积/体积内生成数量 N 的随机采样,权衡每个样本对最终结果的贡献并求和。

蒙特卡洛积分是一个庞大的数学主题,在此不再赘述,但有一点需要提到:生成随机样本的方法也多种多样。默认情况下,每次采样都是我们熟悉的完全(伪)随机,不过利用半随机序列的某些属性,我们可以生成虽然是随机样本但具有一些有趣性质的样本向量。例如,我们可以对一种名为低差异序列的东西进行蒙特卡洛积分,该序列生成的仍然是随机样本,但样本分布更均匀

当使用低差异序列生成蒙特卡洛样本向量时,该过程称为拟蒙特卡洛积分。拟蒙特卡洛方法具有更快的收敛速度,这使得它对于性能繁重的应用很有用。

鉴于我们新获得的有关蒙特卡洛(Monte Carlo)和拟蒙特卡洛(Quasi-Monte Carlo)积分的知识,我们可以使用一个有趣的属性来获得更快的收敛速度,这就是重要性采样。我们在前文已经提到过它,但是在镜面反射的情况下,反射的光向量被限制在镜面波瓣中,波瓣的大小取决于表面的粗糙度。既然镜面波瓣外的任何(拟)随机生成的样本与镜面积分无关,因此将样本集中在镜面波瓣内生成是有意义的,但代价是蒙特卡洛估算会产生偏差。

本质上来说,这就是重要性采样的核心:只在某些区域生成采样向量,该区域围绕微表面半向量,受粗糙度限制。通过将拟蒙特卡洛采样与低差异序列相结合,并使用重要性采样偏置样本向量的方法,我们可以获得很高的收敛速度。因为我们求解的速度更快,所以要达到足够的近似度,我们所需要的样本更少。因此,这套组合方法甚至可以允许图形应用程序实时求解镜面积分,虽然比预计算结果还是要慢得多。

低差异序列

在本教程中,我们将使用重要性采样来预计算间接反射方程的镜面反射部分,该采样基于拟蒙特卡洛方法给出了随机的低差异序列。我们将使用的序列被称为 Hammersley 序列,Holger Dammertz 曾仔细描述过它。Hammersley 序列是基于 Van Der Corput 序列,该序列是把十进制数字的二进制表示镜像翻转到小数点右边而得。(译注:原文为 Van Der Corpus 疑似笔误,下文各处同)

给出一些巧妙的技巧,我们可以在着色器程序中非常有效地生成 Van Der Corput 序列,我们将用它来获得 Hammersley 序列,设总样本数为 N,样本索引为 i:

float RadicalInverse_VdC(uint bits) 
{
    bits = (bits << 16u) | (bits >> 16u);
    bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
    bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
    bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
    bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
    return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}
// ----------------------------------------------------------------------------
vec2 Hammersley(uint i, uint N)
{
    return vec2(float(i)/float(N), RadicalInverse_VdC(i));
}  

GLSL 的 Hammersley 函数可以获取大小为 N 的样本集中的低差异样本 i。

GGX 重要性采样

有别于均匀或纯随机地(比如蒙特卡洛)在积分半球 ΩΩ 产生采样向量,我们的采样会根据粗糙度,偏向微表面的半向量的宏观反射方向。采样过程将与我们之前看到的过程相似:开始一个大循环,生成一个随机(低差异)序列值,用该序列值在切线空间中生成样本向量,将样本向量变换到世界空间并对场景的辐射度采样。不同之处在于,我们现在使用低差异序列值作为输入来生成采样向量:

const uint SAMPLE_COUNT = 4096u;
for(uint i = 0u; i < SAMPLE_COUNT; ++i)
{
    vec2 Xi = Hammersley(i, SAMPLE_COUNT);   
}

此外,要构建采样向量,我们需要一些方法定向和偏移采样向量,以使其朝向特定粗糙度的镜面波瓣方向。我们可以如理论教程中所述使用 NDF,并将 GGX NDF 结合到 Epic Games 所述的球形采样向量的处理中:

vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness)
{
    float a = roughness*roughness;

    float phi = 2.0 * PI * Xi.x;
    float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y));
    float sinTheta = sqrt(1.0 - cosTheta*cosTheta);

    // from spherical coordinates to cartesian coordinates
    vec3 H;
    H.x = cos(phi) * sinTheta;
    H.y = sin(phi) * sinTheta;
    H.z = cosTheta;

    // from tangent-space vector to world-space sample vector
    vec3 up        = abs(N.z) < 0.999 ? vec3(0.0, 0.0, 1.0) : vec3(1.0, 0.0, 0.0);
    vec3 tangent   = normalize(cross(up, N));
    vec3 bitangent = cross(N, tangent);

    vec3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z;
    return normalize(sampleVec);
}

基于特定的粗糙度输入和低差异序列值 Xi,我们获得了一个采样向量,该向量大体围绕着预估的微表面的半向量。注意,根据迪士尼对 PBR 的研究,Epic Games 使用了平方粗糙度以获得更好的视觉效果。

使用低差异 Hammersley 序列和上述定义的样本生成方法,我们可以最终完成预滤波器卷积着色器:

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

uniform samplerCube environmentMap;
uniform float roughness;

const float PI = 3.14159265359;

float RadicalInverse_VdC(uint bits);
vec2 Hammersley(uint i, uint N);
vec3 ImportanceSampleGGX(vec2 Xi, vec3 N, float roughness);

void main()
{       
    vec3 N = normalize(localPos);    
    vec3 R = N;
    vec3 V = R;

    const uint SAMPLE_COUNT = 1024u;
    float totalWeight = 0.0;   
    vec3 prefilteredColor = vec3(0.0);     
    for(uint i = 0u; i < SAMPLE_COUNT; ++i)
    {
        vec2 Xi = Hammersley(i, SAMPLE_COUNT);
        vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
        vec3 L  = normalize(2.0 * dot(V, H) * H - V);

        float NdotL = max(dot(N, L), 0.0);
        if(NdotL > 0.0)
        {
            prefilteredColor += texture(environmentMap, L).rgb * NdotL;
            totalWeight      += NdotL;
        }
    }
    prefilteredColor = prefilteredColor / totalWeight;

    FragColor = vec4(prefilteredColor, 1.0);
}  

输入的粗糙度随着预过滤的立方体贴图的 mipmap 级别变化(从0.0到1.0),我们根据据粗糙度预过滤环境贴图,把结果存在 prefilteredColor 里。再用 prefilteredColor 除以采样权重总和,其中对最终结果影响较小(NdotL 较小)的采样最终权重也较小。

捕获预过滤 mipmap 级别

剩下要做的就是让 OpenGL 在多个 mipmap 级别上以不同的粗糙度值预过滤环境贴图。有了最开始的辐照度教程作为基础,实际上很简单:

prefilterShader.use();
prefilterShader.setInt("environmentMap", 0);
prefilterShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
unsigned int maxMipLevels = 5;
for (unsigned int mip = 0; mip < maxMipLevels; ++mip)
{
    // reisze framebuffer according to mip-level size.
    unsigned int mipWidth  = 128 * std::pow(0.5, mip);
    unsigned int mipHeight = 128 * std::pow(0.5, mip);
    glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
    glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, mipWidth, mipHeight);
    glViewport(0, 0, mipWidth, mipHeight);

    float roughness = (float)mip / (float)(maxMipLevels - 1);
    prefilterShader.setFloat("roughness", roughness);
    for (unsigned int i = 0; i < 6; ++i)
    {
        prefilterShader.setMat4("view", captureViews[i]);
        glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
                               GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, prefilterMap, mip);

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
        renderCube();
    }
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);   

这个过程类似于辐照度贴图卷积,但是这次我们将帧缓冲区缩放到适当的 mipmap 尺寸, mip 级别每增加一级,尺寸缩小为一半。此外,我们在 glFramebufferTexture2D 的最后一个参数中指定要渲染的目标 mip 级别,然后将要预过滤的粗糙度传给预过滤着色器。

这样我们会得到一张经过适当预过滤的环境贴图,访问该贴图时指定的 mip 等级越高,获得的反射就越模糊。如果我们在天空盒着色器中显示这张预过滤的环境立方体贴图,并在其着色器中强制在其第一个 mip 级别以上采样,如下所示:

vec3 envColor = textureLod(environmentMap, WorldPos, 1.2).rgb;

我们得到的结果看起来确实像原始环境的模糊版本:

如果 HDR 环境贴图的预过滤看起来差不多没问题,尝试一下不同的 mipmap 级别,观察预过滤贴图随着 mip 级别增加,反射逐渐从锐利变模糊的过程。

预过滤卷积的伪像

当前的预过滤贴图可以在大多数情况下正常工作,不过你迟早会遇到几个与预过滤卷积直接相关的渲染问题。我将在这里列出最常见的一些问题,以及如何修复它们。

高粗糙度的立方体贴图接缝

在具有粗糙表面的表面上对预过滤贴图采样,也就等同于在较低的 mip 级别上对预过滤贴图采样。在对立方体贴图进行采样时,默认情况下,OpenGL不会在立方体面之间进行线性插值。由于较低的 mip 级别具有更低的分辨率,并且预过滤贴图代表了与更大的采样波瓣卷积,因此缺乏立方体的面和面之间的滤波的问题就更明显:

幸运的是,OpenGL 可以启用 GL_TEXTURE_CUBE_MAP_SEAMLESS,以为我们提供在立方体贴图的面之间进行正确过滤的选项:

glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS);  

开启了以后效果好了非常多 

预过滤卷积的亮点

由于镜面反射中光强度的变化大,高频细节多,所以对镜面反射进行卷积需要大量采样,才能正确反映 HDR 环境反射的混乱变化。我们已经进行了大量的采样,但是在某些环境下,在某些较粗糙的 mip 级别上可能仍然不够,导致明亮区域周围出现点状图案:

一种解决方案是进一步增加样本数量,但在某些情况下还是不够。另一种方案如 Chetan Jags 所述,我们可以在预过滤卷积时,不直接采样环境贴图,而是基于积分的 PDF 和粗糙度采样环境贴图的 mipmap ,以减少伪像:

float D   = DistributionGGX(NdotH, roughness);
float pdf = (D * NdotH / (4.0 * HdotV)) + 0.0001; 

float resolution = 512.0; // resolution of source cubemap (per face)
float saTexel  = 4.0 * PI / (6.0 * resolution * resolution);
float saSample = 1.0 / (float(SAMPLE_COUNT) * pdf + 0.0001);

float mipLevel = roughness == 0.0 ? 0.0 : 0.5 * log2(saSample / saTexel); 

既然要采样 mipmap ,不要忘记在环境贴图上开启三线性过滤:

glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); 

设置立方体贴图的基本纹理后,让 OpenGL 生成 mipmap:

// convert HDR equirectangular environment map to cubemap equivalent
[...]
// then generate mipmaps
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
glGenerateMipmap(GL_TEXTURE_CUBE_MAP);

这个方法效果非常好,可以去除预过滤贴图中较粗糙表面上的大多数甚至全部亮点。

预计算 BRDF

预过滤的环境贴图已经可以设置并运行,我们可以集中精力于求和近似的第二部分:BRDF。让我们再次简要回顾一下镜面部分的分割求和近似法:

我们已经在预过滤贴图的各个粗糙度级别上预计算了分割求和近似的左半部分。右半部分要求我们在 n⋅ωo 、表面粗糙度、菲涅尔系数 F0上计算 BRDF 方程的卷积。这等同于在纯白的环境光或者辐射度恒定为 Li=1.0 的设置下,对镜面 BRDF 求积分。对3个变量做卷积有点复杂,不过我们可以把 F0 移出镜面 BRDF 方程:

F 为菲涅耳方程。将菲涅耳分母移到 BRDF 下面可以得到如下等式:

用 Fresnel-Schlick 近似公式替换右边的 F 可以得到:

让我们用 α 替换 (1−ωo⋅h)5 以便更轻松地求解 F0:

然后我们将菲涅耳函数 F分拆到两个积分里:

这样,F0在整个积分上是恒定的,我们可以从积分中提取出F0。接下来,我们将α替换回其原始形式,从而得到最终分割求和的 BRDF 方程:

公式中的两个积分分别表示 F0的比例和偏差。注意,由于 f(p,ωi,ωo) 已经包含 F 项,它们被约分了,这里的 f 中不计算 F 项。

和之前卷积环境贴图类似,我们可以对 BRDF 方程求卷积,其输入是 n 和 ωo 的夹角,以及粗糙度,并将卷积的结果存储在纹理中。我们将卷积后的结果存储在 2D 查找纹理(Look Up Texture, LUT)中,这张纹理被称为 BRDF 积分贴图,稍后会将其用于 PBR 光照着色器中,以获得间接镜面反射的最终卷积结果。

BRDF 卷积着色器在 2D 平面上执行计算,直接使用其 2D 纹理坐标作为卷积输入(NdotV 和 roughness)。代码与预滤波器的卷积代码大体相似,不同之处在于,它现在根据 BRDF 的几何函数和 Fresnel-Schlick 近似来处理采样向量:

vec2 IntegrateBRDF(float NdotV, float roughness)
{
    vec3 V;
    V.x = sqrt(1.0 - NdotV*NdotV);
    V.y = 0.0;
    V.z = NdotV;

    float A = 0.0;
    float B = 0.0;

    vec3 N = vec3(0.0, 0.0, 1.0);

    const uint SAMPLE_COUNT = 1024u;
    for(uint i = 0u; i < SAMPLE_COUNT; ++i)
    {
        vec2 Xi = Hammersley(i, SAMPLE_COUNT);
        vec3 H  = ImportanceSampleGGX(Xi, N, roughness);
        vec3 L  = normalize(2.0 * dot(V, H) * H - V);

        float NdotL = max(L.z, 0.0);
        float NdotH = max(H.z, 0.0);
        float VdotH = max(dot(V, H), 0.0);

        if(NdotL > 0.0)
        {
            float G = GeometrySmith(N, V, L, roughness);
            float G_Vis = (G * VdotH) / (NdotH * NdotV);
            float Fc = pow(1.0 - VdotH, 5.0);

            A += (1.0 - Fc) * G_Vis;
            B += Fc * G_Vis;
        }
    }
    A /= float(SAMPLE_COUNT);
    B /= float(SAMPLE_COUNT);
    return vec2(A, B);
}
// ----------------------------------------------------------------------------
void main() 
{
    vec2 integratedBRDF = IntegrateBRDF(TexCoords.x, TexCoords.y);
    FragColor = integratedBRDF;
}

如你所见,BRDF 卷积部分是从数学到代码的直接转换。我们将角度 θ 和粗糙度作为输入,以重要性采样产生采样向量,在整个几何体上结合 BRDF 的菲涅耳项对向量进行处理,然后输出每个样本上 F0 的系数和偏差,最后取平均值。

你可能回想起理论教程中的一个细节:与 IBL 一起使用时,BRDF 的几何项略有不同,因为 k 变量的含义稍有不同:

由于 BRDF 卷积是镜面 IBL 积分的一部分,因此我们要在 Schlick-GGX 几何函数中使用 kIBL:

float GeometrySchlickGGX(float NdotV, float roughness)
{
    float a = roughness;
    float k = (a * a) / 2.0;

    float nom   = NdotV;
    float denom = NdotV * (1.0 - k) + k;

    return nom / denom;
}
// ----------------------------------------------------------------------------
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness)
{
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx2 = GeometrySchlickGGX(NdotV, roughness);
    float ggx1 = GeometrySchlickGGX(NdotL, roughness);

    return ggx1 * ggx2;
}  

请注意,虽然 k还是从 a 计算出来的,但这里的 a 不是 roughness 的平方——如同最初对 a 的其他解释那样——在这里我们假装平方过了。我不确定这样处理是否与 Epic Games 或迪士尼原始论文不一致,但是直接将 roughness 赋给 a 得到的 BRDF 积分贴图与 Epic Games 的版本完全一致。

最后,为了存储 BRDF 卷积结果,我们需要生成一张 512 × 512 分辨率的 2D 纹理。

unsigned int brdfLUTTexture;
glGenTextures(1, &brdfLUTTexture);

// pre-allocate enough memory for the LUT texture.
glBindTexture(GL_TEXTURE_2D, brdfLUTTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RG16F, 512, 512, 0, GL_RG, GL_FLOAT, 0);
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);

请注意,我们使用的是 Epic Games 推荐的16位精度浮点格式。将环绕模式设置为 GL_CLAMP_TO_EDGE 以防止边缘采样的伪像。 然后,我们复用同一个帧缓冲区对象,并在 NDC (译注:Normalized Device Coordinates) 屏幕空间四边形上运行此着色器:

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, brdfLUTTexture, 0);

glViewport(0, 0, 512, 512);
brdfShader.use();
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
RenderQuad();

glBindFramebuffer(GL_FRAMEBUFFER, 0);  

分割积分和的 BRDF 卷积部分应该得到以下结果:

用OpenGL渲染出来以后的结果:

得到的结果是相同的。 

预过滤的环境贴图和 BRDF 的 2D LUT 都已经齐备,我们可以根据分割求和近似法重建间接镜面部分积分了。最后合并的结果将被用作间接镜面反射或环境镜面反射。

完成 IBL 反射

为了使反射方程的间接镜面反射部分正确运行,我们需要将分割求和近似法的两个部分缝合在一起。第一步是将预计算的光照数据声明到 PBR 着色器的最上面:

uniform samplerCube prefilterMap;
uniform sampler2D   brdfLUT;  

首先,使用反射向量采样预过滤的环境贴图,获取表面的间接镜面反射。请注意,我们会根据表面粗糙度在合适的 mip 级别采样,以使更粗糙的表面产生更模糊的镜面反射。

void main()
{
    [...]
    vec3 R = reflect(-V, N);   

    const float MAX_REFLECTION_LOD = 4.0;
    vec3 prefilteredColor = textureLod(prefilterMap, R,  roughness * MAX_REFLECTION_LOD).rgb;    
    [...]
}

在预过滤步骤中,我们仅将环境贴图卷积最多 5 个 mip 级别(0到4),此处记为 MAX_REFLECTION_LOD,以确保不会对一个没有数据的 mip 级别采样。 然后我们用已知的材质粗糙度和视线-法线夹角作为输入,采样 BRDF LUT。

vec3 F        = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);
vec2 envBRDF  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

这样我们就从 BRDF LUT 中获得了 F0的系数和偏移,这里我们就直接用间接光菲涅尔项 F 代替F0。把这个结果和 IBL 反射方程左边的预过滤部分结合起来,以重建整个近似积分,存入specular。

于是我们得到了反射方程的间接镜面反射部分。现在,将其与上一节教程中的反射方程的漫反射部分结合起来,我们可以获得完整的 PBR IBL 结果:

vec3 F = FresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness);

vec3 kS = F;
vec3 kD = 1.0 - kS;
kD *= 1.0 - metallic;     

vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse    = irradiance * albedo;

const float MAX_REFLECTION_LOD = 4.0;
vec3 prefilteredColor = textureLod(prefilterMap, R,  roughness * MAX_REFLECTION_LOD).rgb;   
vec2 envBRDF  = texture(brdfLUT, vec2(max(dot(N, V), 0.0), roughness)).rg;
vec3 specular = prefilteredColor * (F * envBRDF.x + envBRDF.y);

vec3 ambient = (kD * diffuse + specular) * ao; 

请注意,specular 没有乘以 kS,因为已经乘过了菲涅耳系数。 现在,在一系列粗糙度和金属度各异的球上运行此代码,我们终于可以在最终的 PBR 渲染器中看到其真实颜色:

最终效果:

 

 

注意事项:

在渲染循环之前,我们预计算了所有 PBR 相关的基于图像的光照数据。出于教育目的,这很好,但对于任何 PBR 的实践应用来说,都不是很漂亮。首先,预计算实际上只需要执行一次,而不是每次启动时都要做。其次,当使用多个环境贴图时,你必须在每次程序启动时全部预计算一遍,这是个必须步骤。

因此,通常只需要一次将环境贴图预计算为辐照度贴图和预过滤贴图,然后将其存储在磁盘上(注意,BRDF 积分贴图不依赖于环境贴图,因此只需要计算或加载一次)。这意味着您需要提出一种自定义图像格式来存储 HDR 立方体贴图,包括其 mip 级别。或者将图像存储为某种可用格式——例如支持存储 mip 级别的 .dds——并按其格式加载。

此外,我们也在教程中描述了整个过程,包括生成预计算的 IBL 图像,以帮助我们进一步了解 PBR 管线。此外还可以通过 cmftStudio 或 IBLBaker 等一些出色的工具为您生成这些预计算贴图,也很好用。

  • 21
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值