[图形学] 基于图像的照明:镜面反射

reference:https://learnopengl.com/#!PBR/IBL/Specular-IBL 

      在前一教程中,我们已经结合了基于图像的照明和PBR,通过预计算辐照度映射作为光照的间接漫反射。在这一教程中,我们将关注反射方程的镜面反射部分:

        

         你将会观察到Cook-Torrance镜面反射部分(乘以kS)在积分上并非常数,并且依赖于入射光方向以及入射的视线方向。尝试为所有的入射光方向包括所有可能的视线方向求解积分是困难的,并且实时计算成本过高。Epic Games提供了一种解决思路,它们为了实时的目的,预先卷积了镜面部分,称为分裂和近似。

         分裂和近似将反射方程的镜面部分分为两个单独的部分,我们可以单独卷积,然后在PBR着色器中组合,用于基于镜面间接图像的照明。类似于我们之前预先做的卷积操作,分裂和近似需要HDR环境贴图作为其卷积输入。为了理解分裂和近似,我们将再次关注反射方程,但这次只关注镜面反射部分(我们在前一教程中提取了漫反射部分):

         

        出于与辐照度卷积相同的性能原因,我们无法实时求解镜面部分的积分,并得到合理的性能。因此,我们最好预先计算积分,以获得类似于镜面反射IBL映射的内容,使用片元的法线对此映射进行采样来完成它。但是,这是一个有点麻烦的地方。我们能够预先计算辐照度映射作为仅依赖于ωi的积分,并且我们可以将恒定的漫反射反射率项移出积分。这一次,从BRDF可以看出,积分不仅仅取决于ωi:

       

        这一次,积分也取决于ω0,我们无法实际上采样具有两个方向向量的预先计算的立方体映射。如前一个教程所述,位置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;

        这样,预过滤的环境卷积就不需要知道视线方向。这意味着当从一个角度观察镜面表面反射时,我们不会得到很好的镜面反射效果,如下图所示;然而,这通常被认为是一个合适的妥协:

        

        等式的第二部分等于镜面积分的BRDF部分。如果我们假设每个方向的入射辐射度都是白色的(因此L(p,x)=1.0),我们可以预先计算BRDF,通过给定的输入粗糙度和法线n以及光方向ωi或n⋅ωi。Epic Games将预先计算的BRDF对每个法线和光线方向的组合的结果存储在2D查找纹理(LUT)中的不同粗糙度值上,称为BRDF积分映射。2D查找纹理向表面的菲涅尔效应输出比例(红色)和偏差值(绿色),为我们提供了分割后镜面反射积分的第二部分:

        

        通过将平面的水平纹理坐标(范围在0.0到1.0之间)视为BRDF的输入n⋅ωi,并将其垂直纹理坐标视为输入的粗糙度值来生成查找纹理。使用此BRDF积分映射和预过滤的环境贴图,我们可以将两者结合起来获得镜面反射积分的结果:

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

        这将会给你一些关于Epic Games的分裂和近似大致接近反射方程的间接镜面反射部分的概述。我们现在尝试自己构建预卷积的部分。

预过滤和HDR环境映射

        预过滤环境映射与我们对辐照度映射做卷积非常相似。不同之处在于我们现在考虑了粗糙度,并在预过滤的映射的mip级别中按顺序存储了更粗糙的反射。

        首先,我们需要生成一个新的立方体贴图来保存预过滤的环境贴图数据。为了确保为其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_WARP_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级别以128x128的每面分辨率存储滤波的镜面反射。对于大多数反射来说,这可能已经足够了,如果你有大量光滑材质(像汽车的反射),则可能需要提高分辨率。

        在上一教程中,我们通过球面坐标生成均匀分布在半球Ω上的样本矢量来对环境贴图进行卷积。虽然这适用于辐照度,但对镜面效果较差。当涉及到镜面反射时,基于表面的粗糙度,光的反射矢量r上大致反射在法线n周围,除非表面非常粗糙:

        

        可能的出射光反射位置的一般形状被称为镜面叶片。随着粗糙度的增加,镜面叶片的大小增加;并且镜面叶片的形状随着入射光方向变化。因此,镜面高光的形状高度依赖于材质。

        当谈到微表面模型时,我们可以将镜面叶片想象为给定一些入射光方向的微平面中间向量的反射向量。大多数光线最终反射落在微平面中间向量周围的镜面叶片中,这一事实对于生成采样向量是有意义的,这意味着落在这一叶片外的采样大概率不生效。这一过程被称为重要度采样。

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

        为了充分理解重要性采样,我们首先需要深入研究一个已知的数学结构,称为蒙特卡洛积分。蒙特卡洛积分主要涉及统计和概率理论。蒙特卡洛帮助我们分散地解决一个整体的统计或数值的问题,而无需考虑所有个体。

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

        另一种方法是选择一个小得多的完全随机的人口子集,测量他们身高的平均结果。这个人口可能只有100人。虽然不如以上答案准确,但你会得到一个相对接近真相的答案。这被称为大数定律。这个想法是,如果你测量一个较小的集合ñ,从总人口中得到真正随机的样本,结果将和真实答案接近,并随着样本数量ñ的增加而变得更接近。

         蒙特卡洛积分建立在这个大数定律的基础上,并采用相同的方法来求解积分,而不是为所有可能的(理论上是无限的)样本值求解积分X,简单地生成从总人口和平均值中随机抽取的样本ñ,增加ñ可以使得我们得到的结果更接近确切答案:

        

        为了求解积分,我们在总体a到b处随机选取N个样本,并将它们加在一起除以样本总数来平均它们。该pdf代表着概率密度函数,它告诉我们特定样本在整个样本集上发生的概率。例如,人口高度的pdf看起来就像这样:

        

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

        当涉及到蒙特卡洛积分时,一些样本可能比其它样本具有更高的生成概率,这就是我们对于任何一般的蒙特卡洛估计,会根据pdf将采样值除以采样概率。到目前为止,在我们估算积分的每个例子中,我们生成的样本是均匀的,具有完全相同的生成机会。到目前为止我们的估计没有偏差,这意味着,随着样本数量不断增加,我们最终可以得到积分的精确解。

        但是,一些蒙特卡罗估计存在偏差,这意味着生成的样本不是完全随机的,而是集中在某些特定的值或方向。这些有偏差的蒙特卡罗有一个更快的收敛速度可以收敛到精确解决方案,但由于它们的偏差性质,它们可能永远不会收敛到精确的解决方案。这通常是可接受的,特别是在计算机图形学中,因为只要结果在视觉上可接受,精确的解决方案就不那么重要了。正如我们很快就会看到的那样,重要性采样所生成的样本偏向于特定方向,在这种情况下,我们通过将每个样本乘以或除以对应的pdf来解决。

        蒙特卡罗积分在计算机图形学中使用非常普遍,因为它是以离散和有效的方法近似连续积分的一种相当直观的方式:取任何面积/体积进行采样(如半球Ω),生成 ñ区域/体积内的随机样本量和总和,并权衡每个样本对最终结果的贡献。

        蒙特卡罗积分是一个广泛的数学主题,我们不会深入探究具体细节,但我们会提到多种方法来生成随机样本。默认情况下,每个样本都是伪随机的,但是通过利用半随机序列的某些属性,我们依然可以生成认为是随机的、而具有有趣属性的样本向量。例如,我们可以对一些低差异序列做蒙特卡罗积分,而仍然生成随机样本,但每个样本的分布更均匀:

        

        当使用低差异序列生成蒙特卡罗样本向量的时候,该过程称为准蒙特卡罗积分。准蒙特卡罗方法更快的收敛速度使得它被应用在很多程序之中。

        鉴于我们新获得的蒙特卡罗和准蒙特卡罗积分的知识,我们可以使用一个有趣的属性来实现更快的收敛速度:重要度采样。我们在本教程之前已经提到了它,但是当涉及到光的镜面反射时,反射光矢量被约束在镜面叶片中,其尺寸由表面的粗糙度决定。镜面外的任何随机生成的样本与镜面积分无关。将样本的生成集中在镜面叶片中是有意义的,尽管这会使得蒙特卡罗估计存在一点偏差。

        这实际上就是重要性采样的含义:在一些区域内生成样本矢量,该区域受到围绕微平面中间向量的粗糙度的限制。通过将准蒙特卡罗采样与低差异序列相结合,并使用重要性采样偏移采样适量,我们得到了高收敛率。因为我们以更快的速度求得了解,所以我们需要更少的样本就能达到足够的近似值。该组合甚至允许图形应用程序实时求解镜面反射积分,尽管它仍然比预计算的结果慢得多。

低差异序列

        在本教程中,我们将使用重要度采样来预先计算间接反射方程的镜面反射部分,给出基于准蒙特卡洛方法的随机低差异序列。我们将要使用的序列称为Hammersley序列,正如Holger Dammertz描述的那样。Hammersley序列基于Van Der Corpus序列,它反映了十进制小数点周围的二进制表示。

        使用一个巧妙的技巧,我们可以在着色器中非常有效地产生一个Van Der Corpus序列,用于生成第i个共N个总样本量的Hammersley序列:

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。

        没有位操作符支持的Hammersley序列

        并非所有与OpenGL相关的驱动程序都支持位运算符(例如WebGL和OpenGL ES2.0),在这种情况下,可以使用不依赖于位运算符的代替版Van Der Corpus Sequence:

float VanDerCorpus(uint n, uint base)
{
    float invBase = 1.0 / float(base);
    float denom   = 1.0;
    float result  = 0.0;

    for(uint i = 0u; i < 32u; ++i)
    {
        if(n > 0u)
        {
            denom   = mod(float(n), 2.0);
            result += denom * invBase;
            invBase = invBase / 2.0;
            n       = uint(float(n) / 2.0);
        }
    }

    return result;
}
// ----------------------------------------------------------------------------
vec2 HammersleyNoBitOps(uint i, uint N)
{
    return vec2(float(i)/float(N), VanDerCorpus(i, 2u));
}

        请注意,由于旧硬件中GLSL循环限制,序列会循环遍历所有可能的32位,该版本性能较差。

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中。得到预过滤的颜色后再除以总权重,其中对最终结果影响较小的样本(相对于NdotL)最终的权重也较小。

捕获预过滤的mipmap级别

        剩下要做的事让OpenGL在多个mipmap级别上对不同粗糙度值的环境贴图进行预过滤。这在漫反射辐照度教程的设置上会相当容易:

prefilterShader.use();
prefilterShader.setInit("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)
{
    // resize 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_POSITION_X + i, prefilterMap, mip);

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

         该过程类似于辐照度映射卷积,但这次我们将帧缓冲区的尺寸缩放到了合适的mipmap比例,每个mip级别将尺寸减小到2.另外,我们在glFramebufferTexture2D最后一个参数中指定了我们渲染到的mip级别,并将我们预过滤的粗糙度传递给预过滤着色器。

        这样我们将得到一个适当的预过滤环境贴图,它会返回模糊反射,我们可以访问它更高的mip级别。如果我们在天空盒着色器中显示与过滤的环境立方体贴图,并在其着色器中优先采样高于第一个mip级别:

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

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

        

        如果结果看起来比较类似,那么你已经成功预过滤HDR环境贴图。尝试使用不同的mipmap级别,以查看预过滤映射在增加mip级别时逐渐从锐利变为模糊反射。

预过滤卷积走样

        虽然在当前的预过滤映射在大多数情况下都能正常运行,但依然会遇到几个与预过滤卷积直接相关的渲染问题。我将在这里列出最为常见的,包括了如何解决它们。

        Cubemap边界具有高粗糙度

        在比较粗糙的表面上对预过滤映射进行采样意味着我们在一些较低的mip级别上对预过滤映射进行采样。对立方体贴图进行采样时,默认情况下,OpenGL不会在立方体贴图面上进行线性插值。由于较低的mip级别具有较低的分辨率,并且预过滤映射与较大的样本叶片进行了卷积,因此立方体面之间的滤波的缺陷变得非常明显:

        

        幸运的是,OpenGL为我们提供了通过启用GL_TEXTURE_CUBE_MAP_SEAMLESS来正确过滤立方体贴图的选项:

glEnable(GL_TEXTURE_CUBE_MAP_SEAMLESS);  

        只要在应用程序启动时某个位置开启该属性,接缝就会消失。

        预过滤器卷积中的亮点

        由于镜面反射中高频细节和剧烈变化的光强,使得镜面反射卷积需要大量样本来解释HDR环境反射广泛变化的性质。我们已经采集了大量样本,但在某些环境中,在一些较粗糙的mip级别上可能仍然不够,在这种情况下,将看到明亮区域周围出现点图案:

        

        一种方式是进一步增加样本数,但这对所有环境都是不够的。正如Chentan Jags所描述的那样,我们可以通过(在预过滤卷积期间)不直接对环境贴图进行采样来减少这种走样,而是基于积分的PDF和粗糙度对环境贴图的mip级别进行采样:

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

        不要忘记在环境贴图上开启三线性过滤,以便从以下位置从mip级别进行采样:

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方程。这类似于将镜面BRDF与纯白色的环境或1.0的恒定辐射Li组合在一起。卷积有3个变量的BRDF比较多,但我们可以将F0移出镜面BRDF方程式:

        

        F为菲涅尔方程。将菲涅尔分母移动到BRDF得到以下等效方程:

        

        用Fresnel-Schlick近似代替最右边的F得到:

        

        用α来代替使得方程看起来更简洁:

        

        然后我们将菲涅尔函数F分为两个积分:

         

        这样的话,F0在积分中是常数,我们可以从积分中取出F0。接下来,我们将α替换回原始形式,得到最终分裂和的BRDF方程:

       

        两个分离的积分分别表示F0的缩放和偏移。注意,由于f(p,ωi,ωo) 已经包含了F的项,它们将抵消,从f中消去了F。

        参照之前卷积环境贴图类似的方式,我们可以输入:n与ωo之间的角度和粗糙度上卷积BRDF方程,并将卷积结果存储在纹理中。我们将卷积的结果存储称为BRDF积分映射的2D查找纹理(LUT),稍后将在PBR照明着色器中使用它来获得最终卷积间接镜面反射的结果。

        BRDF卷积着色器在2D平面上运行,使用其2D纹理坐标直接作为BRDF卷积的输入(NdotV和粗糙度)。卷积编码在很大程度上类似于预过滤卷积,不同之处在于它现在是根据我们的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几何函数将使用

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解释为粗糙度的平方。我不确认这部分是不是和Epic Games或原始的迪士尼论文不太一致,但直接将粗糙度换算为a可以得到Epic Games版本相一致的结果。

        最终,为了存储BRDF卷积结果,我们生成512x512分辨率的2D纹理。

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

// 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位精度浮点格式,需要将wrap模式设置为GL_CLAMP_TO_EDGE防止边缘走样。

        然后,我们重新使用相同的framebuffer对象并在NDC屏幕空间四边形上运行此着色器:

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_FRAMEBUFFER, captureFBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPOMENT24, 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部分应该得到如下结果:

        

        利用预过滤环境映射和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查找纹理中进行采样:

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

        由于缩放和偏移F0(这里我们直接使用间接菲涅尔结果F)来自查找纹理,我们将其与IBL反射方程左边的预过滤部分组合,并将近似积分结果重建为镜面反射。

        这样我们得到了反射方程的镜面反射部分。现在,将它与上一个教程中反射方程的漫反射部分结合起来,得到了完整的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;

        请注意,我们没有给镜面乘以Ks,因为在此处已经有了一个菲涅尔乘法。

        现在,我们在粗糙度和金属度不同的球体上运行这个代码,我们终于可以在最终的PBR渲染器中看到它们真实的颜色:

        

        我们甚至还能做出一些更酷的纹理PBR材质:

        

        或者加载Andrew Maximov这一很棒的免费PBR 3D模型

        

        现在我们的照明看起来更有说服力了。更为方便的是,无论我们使用哪种环境贴图,我们的照明看起来都是正确的。接下来你会看到几个不同的预计算的HDR映射,已经完全动态地改变了照明,但是在不改变单个照明变量的情况下,看起来依然是在物理上正确的:

        

         这次PBR的学习是一段漫长的旅程。中间包含了很多步骤,因此也会出现很多错误,如果你遇到问题,请仔细检查球体场景纹理场景代码demo(包括了所有的着色器),或者在评论中查看并询问。

猜你喜欢

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